- ScreenAnalysisService: Gemini 프롬프트를 멀티스텝(3~5 steps) 출력으로 변경 + 하위 호환 fallback - SlideAnnotationService: 스포트라이트 효과(annotateSlideWithSpotlight), 인트로/아웃트로 슬라이드 생성 - TutorialVideoJob: screen→steps 중첩 루프 + 인트로/아웃트로 씬 추가 - index.blade.php: 단계별 나레이션 편집 UI + 예상 시간 표시 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
572 lines
21 KiB
PHP
572 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Video;
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class SlideAnnotationService
|
|
{
|
|
private const TARGET_WIDTH = 1920;
|
|
private const TARGET_HEIGHT = 1080;
|
|
private const CAPTION_HEIGHT = 100;
|
|
private const MARKER_RADIUS = 20;
|
|
|
|
private string $fontPath;
|
|
|
|
public function __construct()
|
|
{
|
|
// Docker에 설치된 나눔고딕 폰트
|
|
$this->fontPath = '/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf';
|
|
if (! file_exists($this->fontPath)) {
|
|
$this->fontPath = '/usr/share/fonts/truetype/nanum/NanumGothic.ttf';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스크린샷 위에 시각적 어노테이션 추가
|
|
*
|
|
* @param string $imagePath 원본 스크린샷 경로
|
|
* @param array $uiElements UI 요소 배열 [{type, label, x, y, description}]
|
|
* @param int $stepNumber 현재 스텝 번호
|
|
* @param string $caption 하단 캡션 텍스트
|
|
* @param string $outputPath 출력 파일 경로
|
|
* @return string|null 성공 시 출력 경로
|
|
*/
|
|
public function annotateSlide(
|
|
string $imagePath,
|
|
array $uiElements,
|
|
int $stepNumber,
|
|
string $caption,
|
|
string $outputPath
|
|
): ?string {
|
|
if (! file_exists($imagePath)) {
|
|
Log::error("SlideAnnotation: 원본 이미지 없음 - {$imagePath}");
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$dir = dirname($outputPath);
|
|
if (! is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
// 원본 이미지 로드
|
|
$source = $this->loadImage($imagePath);
|
|
if (! $source) {
|
|
return null;
|
|
}
|
|
|
|
$srcW = imagesx($source);
|
|
$srcH = imagesy($source);
|
|
|
|
// 16:9 캔버스 생성
|
|
$canvas = imagecreatetruecolor(self::TARGET_WIDTH, self::TARGET_HEIGHT);
|
|
|
|
// 배경 검정
|
|
$black = imagecolorallocate($canvas, 0, 0, 0);
|
|
imagefill($canvas, 0, 0, $black);
|
|
|
|
// 캡션 영역 제외한 영역에 이미지 리사이즈+센터링
|
|
$availH = self::TARGET_HEIGHT - self::CAPTION_HEIGHT;
|
|
$scale = min(self::TARGET_WIDTH / $srcW, $availH / $srcH);
|
|
$newW = (int) ($srcW * $scale);
|
|
$newH = (int) ($srcH * $scale);
|
|
$offsetX = (int) ((self::TARGET_WIDTH - $newW) / 2);
|
|
$offsetY = (int) (($availH - $newH) / 2);
|
|
|
|
imagecopyresampled($canvas, $source, $offsetX, $offsetY, 0, 0, $newW, $newH, $srcW, $srcH);
|
|
imagedestroy($source);
|
|
|
|
// 반투명 오버레이 (약간 dim)
|
|
$overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 100); // 약간 어둡게
|
|
imagefilledrectangle($canvas, $offsetX, $offsetY, $offsetX + $newW, $offsetY + $newH, $overlay);
|
|
|
|
// UI 요소 마커 그리기
|
|
$this->drawMarkers($canvas, $uiElements, $offsetX, $offsetY, $newW, $newH);
|
|
|
|
// 하단 캡션 바
|
|
$this->drawCaptionBar($canvas, $caption, $stepNumber);
|
|
|
|
// 상단 스텝 인디케이터
|
|
$this->drawStepBadge($canvas, $stepNumber);
|
|
|
|
// PNG로 저장
|
|
imagepng($canvas, $outputPath, 6);
|
|
imagedestroy($canvas);
|
|
|
|
Log::info("SlideAnnotation: 슬라이드 생성 완료", ['output' => $outputPath]);
|
|
|
|
return $outputPath;
|
|
} catch (\Exception $e) {
|
|
Log::error("SlideAnnotation: 예외 발생", ['error' => $e->getMessage()]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* UI 요소 위치에 빨간 번호 마커 그리기
|
|
*/
|
|
private function drawMarkers(\GdImage $canvas, array $uiElements, int $offsetX, int $offsetY, int $imgW, int $imgH): void
|
|
{
|
|
$red = imagecolorallocate($canvas, 239, 68, 68);
|
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
|
$highlightBg = imagecolorallocatealpha($canvas, 239, 68, 68, 100);
|
|
|
|
foreach ($uiElements as $i => $element) {
|
|
$x = $element['x'] ?? 0.5;
|
|
$y = $element['y'] ?? 0.5;
|
|
|
|
// 비율 좌표 → 실제 픽셀 좌표
|
|
$px = $offsetX + (int) ($x * $imgW);
|
|
$py = $offsetY + (int) ($y * $imgH);
|
|
|
|
// 하이라이트 영역 (요소 주변 밝게)
|
|
$hlSize = 60;
|
|
imagefilledrectangle(
|
|
$canvas,
|
|
max(0, $px - $hlSize),
|
|
max(0, $py - $hlSize),
|
|
min(self::TARGET_WIDTH, $px + $hlSize),
|
|
min(self::TARGET_HEIGHT - self::CAPTION_HEIGHT, $py + $hlSize),
|
|
$highlightBg
|
|
);
|
|
|
|
// 빨간 원형 배지
|
|
$r = self::MARKER_RADIUS;
|
|
imagefilledellipse($canvas, $px, $py, $r * 2, $r * 2, $red);
|
|
|
|
// 흰색 테두리
|
|
imageellipse($canvas, $px, $py, $r * 2, $r * 2, $white);
|
|
|
|
// 번호 텍스트
|
|
$num = (string) ($i + 1);
|
|
$fontSize = 14;
|
|
|
|
if (file_exists($this->fontPath)) {
|
|
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $num);
|
|
$tw = $bbox[2] - $bbox[0];
|
|
$th = $bbox[1] - $bbox[7];
|
|
imagettftext($canvas, $fontSize, 0, $px - (int) ($tw / 2), $py + (int) ($th / 2), $white, $this->fontPath, $num);
|
|
} else {
|
|
imagestring($canvas, 5, $px - 4, $py - 7, $num, $white);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 하단 캡션 바 그리기
|
|
*/
|
|
private function drawCaptionBar(\GdImage $canvas, string $caption, int $stepNumber): void
|
|
{
|
|
$barY = self::TARGET_HEIGHT - self::CAPTION_HEIGHT;
|
|
|
|
// 반투명 검정 배경
|
|
$barBg = imagecolorallocatealpha($canvas, 0, 0, 0, 40);
|
|
imagefilledrectangle($canvas, 0, $barY, self::TARGET_WIDTH, self::TARGET_HEIGHT, $barBg);
|
|
|
|
// 상단 구분선 (인디고)
|
|
$accent = imagecolorallocate($canvas, 79, 70, 229);
|
|
imagefilledrectangle($canvas, 0, $barY, self::TARGET_WIDTH, $barY + 3, $accent);
|
|
|
|
// 캡션 텍스트
|
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
|
$fontSize = 20;
|
|
$textY = $barY + 55;
|
|
|
|
if (file_exists($this->fontPath)) {
|
|
// 텍스트 줄바꿈 처리
|
|
$wrappedText = $this->wrapText($caption, 60);
|
|
$lines = explode("\n", $wrappedText);
|
|
|
|
$lineHeight = 30;
|
|
$startY = $barY + 20 + $lineHeight;
|
|
|
|
if (count($lines) > 1) {
|
|
$fontSize = 17;
|
|
$lineHeight = 26;
|
|
$startY = $barY + 15 + $lineHeight;
|
|
}
|
|
|
|
foreach ($lines as $li => $line) {
|
|
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $line);
|
|
$tw = $bbox[2] - $bbox[0];
|
|
$tx = (int) ((self::TARGET_WIDTH - $tw) / 2);
|
|
imagettftext($canvas, $fontSize, 0, $tx, $startY + ($li * $lineHeight), $white, $this->fontPath, $line);
|
|
}
|
|
} else {
|
|
imagestring($canvas, 5, 40, $textY, $caption, $white);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 좌상단 스텝 배지 그리기
|
|
*/
|
|
private function drawStepBadge(\GdImage $canvas, int $stepNumber): void
|
|
{
|
|
$badgeBg = imagecolorallocate($canvas, 79, 70, 229);
|
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
|
|
|
// 둥근 사각형 배지 (좌상단)
|
|
$bx = 30;
|
|
$by = 20;
|
|
$bw = 140;
|
|
$bh = 44;
|
|
|
|
imagefilledrectangle($canvas, $bx, $by, $bx + $bw, $by + $bh, $badgeBg);
|
|
|
|
$text = "STEP {$stepNumber}";
|
|
|
|
if (file_exists($this->fontPath)) {
|
|
$fontSize = 18;
|
|
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $text);
|
|
$tw = $bbox[2] - $bbox[0];
|
|
$tx = $bx + (int) (($bw - $tw) / 2);
|
|
imagettftext($canvas, $fontSize, 0, $tx, $by + 32, $white, $this->fontPath, $text);
|
|
} else {
|
|
imagestring($canvas, 5, $bx + 20, $by + 12, $text, $white);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스포트라이트 효과가 적용된 슬라이드 생성
|
|
*
|
|
* 원본 이미지를 dim 처리한 후, focused_element 영역만 밝게 복원
|
|
*/
|
|
public function annotateSlideWithSpotlight(
|
|
string $imagePath,
|
|
?array $focusedElement,
|
|
int $stepNumber,
|
|
int $totalSteps,
|
|
string $caption,
|
|
string $outputPath
|
|
): ?string {
|
|
if (! file_exists($imagePath)) {
|
|
Log::error("SlideAnnotation: 원본 이미지 없음 - {$imagePath}");
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$dir = dirname($outputPath);
|
|
if (! is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$source = $this->loadImage($imagePath);
|
|
if (! $source) {
|
|
return null;
|
|
}
|
|
|
|
$srcW = imagesx($source);
|
|
$srcH = imagesy($source);
|
|
|
|
// 16:9 캔버스 생성
|
|
$canvas = imagecreatetruecolor(self::TARGET_WIDTH, self::TARGET_HEIGHT);
|
|
$black = imagecolorallocate($canvas, 0, 0, 0);
|
|
imagefill($canvas, 0, 0, $black);
|
|
|
|
// 캡션 영역 제외한 영역에 이미지 배치
|
|
$availH = self::TARGET_HEIGHT - self::CAPTION_HEIGHT;
|
|
$scale = min(self::TARGET_WIDTH / $srcW, $availH / $srcH);
|
|
$newW = (int) ($srcW * $scale);
|
|
$newH = (int) ($srcH * $scale);
|
|
$offsetX = (int) ((self::TARGET_WIDTH - $newW) / 2);
|
|
$offsetY = (int) (($availH - $newH) / 2);
|
|
|
|
// 원본 이미지 배치
|
|
imagecopyresampled($canvas, $source, $offsetX, $offsetY, 0, 0, $newW, $newH, $srcW, $srcH);
|
|
|
|
// 강한 dim 오버레이 (alpha 60 → 상당히 어둡게)
|
|
$dimOverlay = imagecolorallocatealpha($canvas, 0, 0, 0, 60);
|
|
imagefilledrectangle($canvas, $offsetX, $offsetY, $offsetX + $newW, $offsetY + $newH, $dimOverlay);
|
|
|
|
// 스포트라이트: focused_element 영역만 원본 밝기로 복원
|
|
if ($focusedElement) {
|
|
$fx = $focusedElement['x'] ?? 0.5;
|
|
$fy = $focusedElement['y'] ?? 0.5;
|
|
$fw = $focusedElement['w'] ?? 0.2;
|
|
$fh = $focusedElement['h'] ?? 0.15;
|
|
|
|
// 비율 → 원본 이미지 좌표
|
|
$srcFx = (int) ($fx * $srcW);
|
|
$srcFy = (int) ($fy * $srcH);
|
|
$srcFw = (int) ($fw * $srcW);
|
|
$srcFh = (int) ($fh * $srcH);
|
|
|
|
// 비율 → 캔버스 좌표
|
|
$canvasFx = $offsetX + (int) ($fx * $newW);
|
|
$canvasFy = $offsetY + (int) ($fy * $newH);
|
|
$canvasFw = (int) ($fw * $newW);
|
|
$canvasFh = (int) ($fh * $newH);
|
|
|
|
// 경계 보정
|
|
$canvasFw = min($canvasFw, self::TARGET_WIDTH - $canvasFx);
|
|
$canvasFh = min($canvasFh, $availH - $canvasFy + $offsetY);
|
|
|
|
// 원본 이미지에서 해당 영역을 다시 복사 (dim 위에 덮어씌움)
|
|
if ($canvasFw > 0 && $canvasFh > 0) {
|
|
imagecopyresampled(
|
|
$canvas, $source,
|
|
$canvasFx, $canvasFy,
|
|
$srcFx, $srcFy,
|
|
$canvasFw, $canvasFh,
|
|
$srcFw, $srcFh
|
|
);
|
|
|
|
// 빨간 테두리
|
|
$red = imagecolorallocate($canvas, 239, 68, 68);
|
|
$borderW = 3;
|
|
for ($b = 0; $b < $borderW; $b++) {
|
|
imagerectangle(
|
|
$canvas,
|
|
$canvasFx - $b, $canvasFy - $b,
|
|
$canvasFx + $canvasFw + $b, $canvasFy + $canvasFh + $b,
|
|
$red
|
|
);
|
|
}
|
|
|
|
// 번호 마커 (좌상단)
|
|
$this->drawNumberMarker($canvas, $stepNumber, $canvasFx - 5, $canvasFy - 5);
|
|
}
|
|
}
|
|
|
|
imagedestroy($source);
|
|
|
|
// 하단 캡션 바
|
|
$this->drawCaptionBar($canvas, $caption, $stepNumber);
|
|
|
|
// 상단 STEP 배지 (진행 표시)
|
|
$this->drawStepProgressBadge($canvas, $stepNumber, $totalSteps);
|
|
|
|
// PNG로 저장
|
|
imagepng($canvas, $outputPath, 6);
|
|
imagedestroy($canvas);
|
|
|
|
Log::info("SlideAnnotation: 스포트라이트 슬라이드 생성 완료", ['output' => $outputPath]);
|
|
|
|
return $outputPath;
|
|
} catch (\Exception $e) {
|
|
Log::error("SlideAnnotation: 스포트라이트 예외 발생", ['error' => $e->getMessage()]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 인트로 슬라이드 생성
|
|
*/
|
|
public function createIntroSlide(string $title, string $outputPath): ?string
|
|
{
|
|
try {
|
|
$dir = dirname($outputPath);
|
|
if (! is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$canvas = imagecreatetruecolor(self::TARGET_WIDTH, self::TARGET_HEIGHT);
|
|
|
|
// 인디고 그라데이션 배경
|
|
$topColor = [49, 46, 129]; // indigo-900
|
|
$bottomColor = [79, 70, 229]; // indigo-600
|
|
for ($y = 0; $y < self::TARGET_HEIGHT; $y++) {
|
|
$ratio = $y / self::TARGET_HEIGHT;
|
|
$r = (int) ($topColor[0] + ($bottomColor[0] - $topColor[0]) * $ratio);
|
|
$g = (int) ($topColor[1] + ($bottomColor[1] - $topColor[1]) * $ratio);
|
|
$b = (int) ($topColor[2] + ($bottomColor[2] - $topColor[2]) * $ratio);
|
|
$color = imagecolorallocate($canvas, $r, $g, $b);
|
|
imageline($canvas, 0, $y, self::TARGET_WIDTH, $y, $color);
|
|
}
|
|
|
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
|
$lightWhite = imagecolorallocate($canvas, 200, 200, 220);
|
|
|
|
if (file_exists($this->fontPath)) {
|
|
// 타이틀
|
|
$titleSize = 42;
|
|
$bbox = imagettfbbox($titleSize, 0, $this->fontPath, $title);
|
|
$tw = $bbox[2] - $bbox[0];
|
|
$tx = (int) ((self::TARGET_WIDTH - $tw) / 2);
|
|
imagettftext($canvas, $titleSize, 0, $tx, 460, $white, $this->fontPath, $title);
|
|
|
|
// 서브타이틀
|
|
$sub = 'SAM 사용법을 안내합니다.';
|
|
$subSize = 24;
|
|
$bbox2 = imagettfbbox($subSize, 0, $this->fontPath, $sub);
|
|
$tw2 = $bbox2[2] - $bbox2[0];
|
|
$tx2 = (int) ((self::TARGET_WIDTH - $tw2) / 2);
|
|
imagettftext($canvas, $subSize, 0, $tx2, 530, $lightWhite, $this->fontPath, $sub);
|
|
|
|
// 구분선
|
|
$lineColor = imagecolorallocatealpha($canvas, 255, 255, 255, 80);
|
|
imagefilledrectangle($canvas, (self::TARGET_WIDTH / 2) - 60, 560, (self::TARGET_WIDTH / 2) + 60, 563, $lineColor);
|
|
} else {
|
|
imagestring($canvas, 5, self::TARGET_WIDTH / 2 - 100, 450, $title, $white);
|
|
}
|
|
|
|
imagepng($canvas, $outputPath, 6);
|
|
imagedestroy($canvas);
|
|
|
|
return $outputPath;
|
|
} catch (\Exception $e) {
|
|
Log::error("SlideAnnotation: 인트로 슬라이드 생성 실패", ['error' => $e->getMessage()]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 아웃트로 슬라이드 생성
|
|
*/
|
|
public function createOutroSlide(string $title, string $outputPath): ?string
|
|
{
|
|
try {
|
|
$dir = dirname($outputPath);
|
|
if (! is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$canvas = imagecreatetruecolor(self::TARGET_WIDTH, self::TARGET_HEIGHT);
|
|
|
|
// 진한 그라데이션 배경
|
|
$topColor = [30, 27, 75]; // indigo-950
|
|
$bottomColor = [49, 46, 129]; // indigo-900
|
|
for ($y = 0; $y < self::TARGET_HEIGHT; $y++) {
|
|
$ratio = $y / self::TARGET_HEIGHT;
|
|
$r = (int) ($topColor[0] + ($bottomColor[0] - $topColor[0]) * $ratio);
|
|
$g = (int) ($topColor[1] + ($bottomColor[1] - $topColor[1]) * $ratio);
|
|
$b = (int) ($topColor[2] + ($bottomColor[2] - $topColor[2]) * $ratio);
|
|
$color = imagecolorallocate($canvas, $r, $g, $b);
|
|
imageline($canvas, 0, $y, self::TARGET_WIDTH, $y, $color);
|
|
}
|
|
|
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
|
$lightWhite = imagecolorallocate($canvas, 180, 180, 200);
|
|
|
|
if (file_exists($this->fontPath)) {
|
|
$mainText = '이상으로 안내를 마칩니다.';
|
|
$mainSize = 36;
|
|
$bbox = imagettfbbox($mainSize, 0, $this->fontPath, $mainText);
|
|
$tw = $bbox[2] - $bbox[0];
|
|
$tx = (int) ((self::TARGET_WIDTH - $tw) / 2);
|
|
imagettftext($canvas, $mainSize, 0, $tx, 470, $white, $this->fontPath, $mainText);
|
|
|
|
$subText = '감사합니다.';
|
|
$subSize = 24;
|
|
$bbox2 = imagettfbbox($subSize, 0, $this->fontPath, $subText);
|
|
$tw2 = $bbox2[2] - $bbox2[0];
|
|
$tx2 = (int) ((self::TARGET_WIDTH - $tw2) / 2);
|
|
imagettftext($canvas, $subSize, 0, $tx2, 530, $lightWhite, $this->fontPath, $subText);
|
|
} else {
|
|
imagestring($canvas, 5, self::TARGET_WIDTH / 2 - 100, 470, 'Thank you', $white);
|
|
}
|
|
|
|
imagepng($canvas, $outputPath, 6);
|
|
imagedestroy($canvas);
|
|
|
|
return $outputPath;
|
|
} catch (\Exception $e) {
|
|
Log::error("SlideAnnotation: 아웃트로 슬라이드 생성 실패", ['error' => $e->getMessage()]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 번호 마커 그리기 (스포트라이트용)
|
|
*/
|
|
private function drawNumberMarker(\GdImage $canvas, int $number, int $cx, int $cy): void
|
|
{
|
|
$red = imagecolorallocate($canvas, 239, 68, 68);
|
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
|
$r = self::MARKER_RADIUS;
|
|
|
|
imagefilledellipse($canvas, $cx, $cy, $r * 2, $r * 2, $red);
|
|
imageellipse($canvas, $cx, $cy, $r * 2, $r * 2, $white);
|
|
|
|
$num = (string) $number;
|
|
$fontSize = 14;
|
|
|
|
if (file_exists($this->fontPath)) {
|
|
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $num);
|
|
$tw = $bbox[2] - $bbox[0];
|
|
$th = $bbox[1] - $bbox[7];
|
|
imagettftext($canvas, $fontSize, 0, $cx - (int) ($tw / 2), $cy + (int) ($th / 2), $white, $this->fontPath, $num);
|
|
} else {
|
|
imagestring($canvas, 5, $cx - 4, $cy - 7, $num, $white);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 상단 STEP 진행 배지 (STEP 1/5 형식)
|
|
*/
|
|
private function drawStepProgressBadge(\GdImage $canvas, int $stepNumber, int $totalSteps): void
|
|
{
|
|
$badgeBg = imagecolorallocate($canvas, 79, 70, 229);
|
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
|
|
|
$bx = 30;
|
|
$by = 20;
|
|
$bw = 170;
|
|
$bh = 44;
|
|
|
|
imagefilledrectangle($canvas, $bx, $by, $bx + $bw, $by + $bh, $badgeBg);
|
|
|
|
$text = "STEP {$stepNumber}/{$totalSteps}";
|
|
|
|
if (file_exists($this->fontPath)) {
|
|
$fontSize = 18;
|
|
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $text);
|
|
$tw = $bbox[2] - $bbox[0];
|
|
$tx = $bx + (int) (($bw - $tw) / 2);
|
|
imagettftext($canvas, $fontSize, 0, $tx, $by + 32, $white, $this->fontPath, $text);
|
|
} else {
|
|
imagestring($canvas, 5, $bx + 20, $by + 12, $text, $white);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 이미지 로드 (형식 자동 감지)
|
|
*/
|
|
private function loadImage(string $path): ?\GdImage
|
|
{
|
|
$info = getimagesize($path);
|
|
if (! $info) {
|
|
Log::error("SlideAnnotation: 이미지 정보를 읽을 수 없음 - {$path}");
|
|
return null;
|
|
}
|
|
|
|
return match ($info[2]) {
|
|
IMAGETYPE_PNG => imagecreatefrompng($path),
|
|
IMAGETYPE_JPEG => imagecreatefromjpeg($path),
|
|
IMAGETYPE_GIF => imagecreatefromgif($path),
|
|
IMAGETYPE_WEBP => imagecreatefromwebp($path),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 텍스트 줄바꿈
|
|
*/
|
|
private function wrapText(string $text, int $maxChars): string
|
|
{
|
|
if (mb_strlen($text) <= $maxChars) {
|
|
return $text;
|
|
}
|
|
|
|
$words = preg_split('/(?<=\s)|(?<=\.)|(?<=,)/u', $text);
|
|
$lines = [];
|
|
$currentLine = '';
|
|
|
|
foreach ($words as $word) {
|
|
if (mb_strlen($currentLine . $word) > $maxChars && $currentLine !== '') {
|
|
$lines[] = trim($currentLine);
|
|
$currentLine = $word;
|
|
} else {
|
|
$currentLine .= $word;
|
|
}
|
|
}
|
|
|
|
if ($currentLine !== '') {
|
|
$lines[] = trim($currentLine);
|
|
}
|
|
|
|
return implode("\n", array_slice($lines, 0, 3));
|
|
}
|
|
}
|