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