Files
sam-manage/app/Services/ESign/PdfSignatureService.php
김보곤 fd5d052cb7 feat:PDF 서명 오버레이 합성 기능 구현
- PdfSignatureService 신규 생성 (FPDI+TCPDF 기반 PDF 합성)
- submitSignature에서 모든 서명 완료 시 자동 PDF 합성 호출
- downloadDocument에서 서명 완료 PDF 우선 제공
- setasign/fpdi, tecnickcom/tcpdf 패키지 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 19:42:08 +09:00

199 lines
6.2 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
{
/**
* 모든 서명 필드를 원본 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 위에 오버레이한다.
*/
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-m-d');
}
if (! $dateText) {
$dateText = now()->format('Y-m-d');
}
$this->renderText($pdf, $dateText, $x, $y, $w, $h);
}
/**
* 텍스트 필드를 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);
}
/**
* 체크박스를 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); // ✓ (UTF-8)
}
/**
* 텍스트를 지정 영역에 렌더링하는 공통 메서드.
*/
private function renderText(Fpdi $pdf, string $text, float $x, float $y, float $w, float $h): void
{
// 영역 높이에 맞춰 폰트 크기 산출 (pt 단위, 여백 고려)
$fontSize = min($h * 0.7, 12);
if ($fontSize < 4) {
$fontSize = 4;
}
$pdf->SetFont('helvetica', '', $fontSize);
$pdf->SetTextColor(0, 0, 0);
// 텍스트를 영역 중앙에 배치
$pdf->SetXY($x, $y);
$pdf->Cell($w, $h, $text, 0, 0, 'C', false, '', 0, false, 'T', 'M');
}
}