- TCPDF K_PATH_FONTS를 storage/fonts/tcpdf/로 설정하여 vendor 쓰기 권한 문제 해결 - 사전 생성된 Pretendard 폰트 정의 파일 포함 (런타임 생성 불필요) - downloadDocument() 에러 로깅 상세화 (trace 포함)
308 lines
10 KiB
PHP
308 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services\ESign;
|
|
|
|
use App\Models\ESign\EsignContract;
|
|
use App\Models\ESign\EsignSignField;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use setasign\Fpdi\Tcpdf\Fpdi;
|
|
|
|
class PdfSignatureService
|
|
{
|
|
private ?string $koreanFontName = null;
|
|
|
|
public function __construct()
|
|
{
|
|
// TCPDF 클래스 로드 전에 K_PATH_FONTS를 쓰기 가능한 디렉토리로 설정
|
|
// (운영서버에서 vendor/tecnickcom/tcpdf/fonts/ 쓰기 권한 없는 문제 방지)
|
|
if (! defined('K_PATH_FONTS')) {
|
|
$tcpdfFontsDir = dirname(__DIR__, 3).'/storage/fonts/tcpdf/';
|
|
if (is_dir($tcpdfFontsDir)) {
|
|
define('K_PATH_FONTS', $tcpdfFontsDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pretendard 한글 폰트를 등록하고 폰트 이름을 반환한다.
|
|
* 사전 생성된 폰트 정의 파일이 storage/fonts/tcpdf/에 있으면 바로 사용한다.
|
|
*/
|
|
private function getKoreanFont(): string
|
|
{
|
|
if ($this->koreanFontName) {
|
|
return $this->koreanFontName;
|
|
}
|
|
|
|
// 사전 생성된 폰트 정의 파일이 K_PATH_FONTS에 있으면 바로 사용
|
|
if (defined('K_PATH_FONTS') && file_exists(K_PATH_FONTS.'pretendard.php')) {
|
|
$this->koreanFontName = 'pretendard';
|
|
|
|
return $this->koreanFontName;
|
|
}
|
|
|
|
// 폴백: TTF에서 런타임 생성 시도
|
|
$fontPath = storage_path('fonts/Pretendard-Regular.ttf');
|
|
if (file_exists($fontPath)) {
|
|
try {
|
|
$this->koreanFontName = \TCPDF_FONTS::addTTFfont($fontPath, 'TrueTypeUnicode', '', 96);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('TCPDF 한글 폰트 등록 실패', ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
return $this->koreanFontName ?: 'helvetica';
|
|
}
|
|
|
|
/**
|
|
* 모든 서명 필드를 원본 PDF 위에 합성하여 서명 완료 PDF를 생성한다.
|
|
*/
|
|
public function mergeSignatures(EsignContract $contract): string
|
|
{
|
|
$disk = Storage::disk('local');
|
|
$originalPath = $disk->path($contract->original_file_path);
|
|
|
|
if (! file_exists($originalPath)) {
|
|
throw new \RuntimeException("원본 PDF 파일이 존재하지 않습니다: {$contract->original_file_path}");
|
|
}
|
|
|
|
// FPDI(TCPDF 확장)로 원본 PDF 임포트
|
|
$pdf = new Fpdi;
|
|
$pdf->setPrintHeader(false);
|
|
$pdf->setPrintFooter(false);
|
|
|
|
$pageCount = $pdf->setSourceFile($originalPath);
|
|
|
|
// 서명 필드를 페이지별로 그룹핑
|
|
$signFields = EsignSignField::withoutGlobalScopes()
|
|
->where('contract_id', $contract->id)
|
|
->with('signer')
|
|
->orderBy('page_number')
|
|
->orderBy('sort_order')
|
|
->get()
|
|
->groupBy('page_number');
|
|
|
|
// 각 페이지를 임포트하고 서명 필드 오버레이
|
|
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
|
|
$templateId = $pdf->importPage($pageNo);
|
|
$size = $pdf->getTemplateSize($templateId);
|
|
|
|
$pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
|
|
$pdf->useTemplate($templateId, 0, 0, $size['width'], $size['height']);
|
|
|
|
// 이 페이지에 배치된 서명 필드 오버레이
|
|
if ($signFields->has($pageNo)) {
|
|
foreach ($signFields[$pageNo] as $field) {
|
|
$this->overlayField($pdf, $field, $size['width'], $size['height']);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 결과 PDF 저장
|
|
$signedDir = "esign/{$contract->tenant_id}/signed";
|
|
$signedRelPath = "{$signedDir}/{$contract->id}_signed.pdf";
|
|
$signedAbsPath = $disk->path($signedRelPath);
|
|
|
|
// 디렉토리 생성
|
|
if (! is_dir(dirname($signedAbsPath))) {
|
|
mkdir(dirname($signedAbsPath), 0755, true);
|
|
}
|
|
|
|
$pdf->Output($signedAbsPath, 'F');
|
|
|
|
// DB 업데이트
|
|
$contract->update([
|
|
'signed_file_path' => $signedRelPath,
|
|
'signed_file_hash' => hash_file('sha256', $signedAbsPath),
|
|
]);
|
|
|
|
Log::info('PDF 서명 합성 완료', [
|
|
'contract_id' => $contract->id,
|
|
'signed_file_path' => $signedRelPath,
|
|
]);
|
|
|
|
return $signedRelPath;
|
|
}
|
|
|
|
/**
|
|
* 서명/도장을 제외한 필드(텍스트, 날짜, 체크박스)만 합성한 미리보기 PDF를 생성한다.
|
|
* 서명 전 문서 확인 시 사용된다.
|
|
*/
|
|
public function generatePreview(EsignContract $contract): string
|
|
{
|
|
$disk = Storage::disk('local');
|
|
$originalPath = $disk->path($contract->original_file_path);
|
|
|
|
if (! file_exists($originalPath)) {
|
|
throw new \RuntimeException("원본 PDF 파일이 존재하지 않습니다: {$contract->original_file_path}");
|
|
}
|
|
|
|
$pdf = new Fpdi;
|
|
$pdf->setPrintHeader(false);
|
|
$pdf->setPrintFooter(false);
|
|
|
|
$pageCount = $pdf->setSourceFile($originalPath);
|
|
|
|
// 서명/도장을 제외한 필드만 조회
|
|
$signFields = EsignSignField::withoutGlobalScopes()
|
|
->where('contract_id', $contract->id)
|
|
->whereNotIn('field_type', ['signature', 'stamp'])
|
|
->with('signer')
|
|
->orderBy('page_number')
|
|
->orderBy('sort_order')
|
|
->get()
|
|
->groupBy('page_number');
|
|
|
|
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
|
|
$templateId = $pdf->importPage($pageNo);
|
|
$size = $pdf->getTemplateSize($templateId);
|
|
|
|
$pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
|
|
$pdf->useTemplate($templateId, 0, 0, $size['width'], $size['height']);
|
|
|
|
if ($signFields->has($pageNo)) {
|
|
foreach ($signFields[$pageNo] as $field) {
|
|
$this->overlayField($pdf, $field, $size['width'], $size['height']);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 미리보기 PDF 저장
|
|
$previewDir = "esign/{$contract->tenant_id}/preview";
|
|
$previewRelPath = "{$previewDir}/{$contract->id}_preview.pdf";
|
|
$previewAbsPath = $disk->path($previewRelPath);
|
|
|
|
if (! is_dir(dirname($previewAbsPath))) {
|
|
mkdir(dirname($previewAbsPath), 0755, true);
|
|
}
|
|
|
|
$pdf->Output($previewAbsPath, 'F');
|
|
|
|
return $previewRelPath;
|
|
}
|
|
|
|
/**
|
|
* 개별 필드를 PDF 위에 오버레이한다.
|
|
*/
|
|
private function overlayField(Fpdi $pdf, EsignSignField $field, float $pageWidth, float $pageHeight): void
|
|
{
|
|
// % 좌표 → PDF pt 좌표 변환
|
|
$x = ($field->position_x / 100) * $pageWidth;
|
|
$y = ($field->position_y / 100) * $pageHeight;
|
|
$w = ($field->width / 100) * $pageWidth;
|
|
$h = ($field->height / 100) * $pageHeight;
|
|
|
|
switch ($field->field_type) {
|
|
case 'signature':
|
|
case 'stamp':
|
|
$this->overlayImage($pdf, $field, $x, $y, $w, $h);
|
|
break;
|
|
|
|
case 'date':
|
|
$this->overlayDate($pdf, $field, $x, $y, $w, $h);
|
|
break;
|
|
|
|
case 'text':
|
|
$this->overlayText($pdf, $field, $x, $y, $w, $h);
|
|
break;
|
|
|
|
case 'checkbox':
|
|
$this->overlayCheckbox($pdf, $field, $x, $y, $w, $h);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 서명/도장 이미지를 PDF에 배치한다.
|
|
*/
|
|
private function overlayImage(Fpdi $pdf, EsignSignField $field, float $x, float $y, float $w, float $h): void
|
|
{
|
|
$signer = $field->signer;
|
|
if (! $signer || ! $signer->signature_image_path) {
|
|
return;
|
|
}
|
|
|
|
$imagePath = Storage::disk('local')->path($signer->signature_image_path);
|
|
if (! file_exists($imagePath)) {
|
|
Log::warning('서명 이미지 파일 없음', [
|
|
'signer_id' => $signer->id,
|
|
'path' => $signer->signature_image_path,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
$pdf->Image($imagePath, $x, $y, $w, $h, 'PNG');
|
|
}
|
|
|
|
/**
|
|
* 날짜 텍스트를 PDF에 렌더링한다.
|
|
*/
|
|
private function overlayDate(Fpdi $pdf, EsignSignField $field, float $x, float $y, float $w, float $h): void
|
|
{
|
|
$signer = $field->signer;
|
|
$dateText = $field->field_value;
|
|
|
|
if (! $dateText && $signer && $signer->signed_at) {
|
|
$dateText = $signer->signed_at->format('Y년 n월 j일');
|
|
}
|
|
|
|
if (! $dateText) {
|
|
$dateText = now()->format('Y년 n월 j일');
|
|
}
|
|
|
|
// 날짜 필드는 폰트 크기를 일관되게 유지 (미지정 시 12pt 고정)
|
|
$fontSize = $field->font_size ?: 12;
|
|
$this->renderText($pdf, $dateText, $x, $y, $w, $h, $fontSize, $field->text_align ?? 'L');
|
|
}
|
|
|
|
/**
|
|
* 텍스트 필드를 PDF에 렌더링한다.
|
|
*/
|
|
private function overlayText(Fpdi $pdf, EsignSignField $field, float $x, float $y, float $w, float $h): void
|
|
{
|
|
$text = $field->field_value ?? '';
|
|
if ($text === '') {
|
|
return;
|
|
}
|
|
|
|
$this->renderText($pdf, $text, $x, $y, $w, $h, $field->font_size, $field->text_align ?? 'L');
|
|
}
|
|
|
|
/**
|
|
* 체크박스를 PDF에 렌더링한다.
|
|
*/
|
|
private function overlayCheckbox(Fpdi $pdf, EsignSignField $field, float $x, float $y, float $w, float $h): void
|
|
{
|
|
if (! $field->field_value) {
|
|
return;
|
|
}
|
|
|
|
$this->renderText($pdf, "\xe2\x9c\x93", $x, $y, $w, $h, $field->font_size, 'C'); // ✓ (UTF-8)
|
|
}
|
|
|
|
/**
|
|
* 텍스트를 지정 영역에 렌더링하는 공통 메서드.
|
|
*/
|
|
private function renderText(Fpdi $pdf, string $text, float $x, float $y, float $w, float $h, ?int $fieldFontSize = null, string $textAlign = 'L'): void
|
|
{
|
|
// 필드에 지정된 폰트 크기 우선, 없으면 영역 높이 기반 자동 산출 (2배 확대)
|
|
if ($fieldFontSize && $fieldFontSize >= 4) {
|
|
$fontSize = $fieldFontSize;
|
|
} else {
|
|
$fontSize = min($h * 1.4, 24);
|
|
if ($fontSize < 6) {
|
|
$fontSize = 6;
|
|
}
|
|
}
|
|
|
|
$pdf->SetFont($this->getKoreanFont(), '', $fontSize);
|
|
$pdf->SetTextColor(0, 0, 0);
|
|
|
|
// 텍스트를 지정된 정렬 방식으로 배치 (L=왼쪽, C=가운데, R=오른쪽)
|
|
$align = in_array($textAlign, ['L', 'C', 'R']) ? $textAlign : 'L';
|
|
$pdf->SetXY($x, $y);
|
|
$pdf->Cell($w, $h, $text, 0, 0, $align, false, '', 0, false, 'T', 'M');
|
|
}
|
|
}
|