feat:PDF 서명 오버레이 합성 기능 구현
- PdfSignatureService 신규 생성 (FPDI+TCPDF 기반 PDF 합성) - submitSignature에서 모든 서명 완료 시 자동 PDF 합성 호출 - downloadDocument에서 서명 완료 PDF 우선 제공 - setasign/fpdi, tecnickcom/tcpdf 패키지 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
use Illuminate\Http\Request;
|
||||
use App\Mail\EsignRequestMail;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use App\Services\ESign\PdfSignatureService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
@@ -243,6 +244,17 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
// PDF에 서명 이미지/텍스트 합성
|
||||
try {
|
||||
$pdfService = new PdfSignatureService();
|
||||
$pdfService->mergeSignatures($contract);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('PDF 서명 합성 실패', [
|
||||
'contract_id' => $contract->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$contract->update(['status' => 'partially_signed']);
|
||||
|
||||
@@ -322,13 +334,18 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse
|
||||
return response()->json(['success' => false, 'message' => '문서를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
if (! Storage::disk('local')->exists($contract->original_file_path)) {
|
||||
// 서명 완료된 PDF가 있으면 우선 제공
|
||||
$filePath = $contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)
|
||||
? $contract->signed_file_path
|
||||
: $contract->original_file_path;
|
||||
|
||||
if (! Storage::disk('local')->exists($filePath)) {
|
||||
return response()->json(['success' => false, 'message' => '문서 파일이 존재하지 않습니다.'], 404);
|
||||
}
|
||||
|
||||
$fileName = $contract->original_file_name ?: ($contract->title . '.pdf');
|
||||
|
||||
return Storage::disk('local')->download($contract->original_file_path, $fileName, [
|
||||
return Storage::disk('local')->download($filePath, $fileName, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
]);
|
||||
}
|
||||
|
||||
198
app/Services/ESign/PdfSignatureService.php
Normal file
198
app/Services/ESign/PdfSignatureService.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,10 @@
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.2",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"setasign/fpdi": "^2.6",
|
||||
"spatie/laravel-permission": "^6.23",
|
||||
"stevebauman/purify": "^6.3"
|
||||
"stevebauman/purify": "^6.3",
|
||||
"tecnickcom/tcpdf": "^6.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
147
composer.lock
generated
147
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "26a03aa10c3c63714cf032ce8573ce3a",
|
||||
"content-hash": "83c51821a6ceb02518fc31ba200a6226",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -3793,6 +3793,78 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "setasign/fpdi",
|
||||
"version": "v2.6.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Setasign/FPDI.git",
|
||||
"reference": "4b53852fde2734ec6a07e458a085db627c60eada"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/4b53852fde2734ec6a07e458a085db627c60eada",
|
||||
"reference": "4b53852fde2734ec6a07e458a085db627c60eada",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-zlib": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"setasign/tfpdf": "<1.31"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7",
|
||||
"setasign/fpdf": "~1.8.6",
|
||||
"setasign/tfpdf": "~1.33",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"tecnickcom/tcpdf": "^6.8"
|
||||
},
|
||||
"suggest": {
|
||||
"setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"setasign\\Fpdi\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jan Slabon",
|
||||
"email": "jan.slabon@setasign.com",
|
||||
"homepage": "https://www.setasign.com"
|
||||
},
|
||||
{
|
||||
"name": "Maximilian Kresse",
|
||||
"email": "maximilian.kresse@setasign.com",
|
||||
"homepage": "https://www.setasign.com"
|
||||
}
|
||||
],
|
||||
"description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
|
||||
"homepage": "https://www.setasign.com/fpdi",
|
||||
"keywords": [
|
||||
"fpdf",
|
||||
"fpdi",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Setasign/FPDI/issues",
|
||||
"source": "https://github.com/Setasign/FPDI/tree/v2.6.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-05T09:57:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-permission",
|
||||
"version": "6.24.0",
|
||||
@@ -6571,6 +6643,77 @@
|
||||
],
|
||||
"time": "2025-12-04T18:11:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tecnickcom/tcpdf",
|
||||
"version": "6.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tecnickcom/TCPDF.git",
|
||||
"reference": "7a2701251e5d52fc3d508fd71704683eb54f5939"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/7a2701251e5d52fc3d508fd71704683eb54f5939",
|
||||
"reference": "7a2701251e5d52fc3d508fd71704683eb54f5939",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"php": ">=7.1.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"config",
|
||||
"include",
|
||||
"tcpdf.php",
|
||||
"tcpdf_barcodes_1d.php",
|
||||
"tcpdf_barcodes_2d.php",
|
||||
"include/tcpdf_colors.php",
|
||||
"include/tcpdf_filters.php",
|
||||
"include/tcpdf_font_data.php",
|
||||
"include/tcpdf_fonts.php",
|
||||
"include/tcpdf_images.php",
|
||||
"include/tcpdf_static.php",
|
||||
"include/barcodes/datamatrix.php",
|
||||
"include/barcodes/pdf417.php",
|
||||
"include/barcodes/qrcode.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicola Asuni",
|
||||
"email": "info@tecnick.com",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "TCPDF is a PHP class for generating PDF documents and barcodes.",
|
||||
"homepage": "http://www.tcpdf.org/",
|
||||
"keywords": [
|
||||
"PDFD32000-2008",
|
||||
"TCPDF",
|
||||
"barcodes",
|
||||
"datamatrix",
|
||||
"pdf",
|
||||
"pdf417",
|
||||
"qrcode"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/tecnickcom/TCPDF/issues",
|
||||
"source": "https://github.com/tecnickcom/TCPDF/tree/6.10.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-21T10:58:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.4.0",
|
||||
@@ -9170,5 +9313,5 @@
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user