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'); } }