Files
sam-kd/ocr/index.php

1388 lines
51 KiB
PHP
Raw Normal View History

<?php
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/load_header.php");
// 권한 체크 (레벨 5 이하만 접근)
if ($level > 5) {
echo "<script>alert('접근 권한이 없습니다.'); history.back();</script>";
exit;
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>사업자등록증 OCR</title>
<style>
.ocr-container {
max-width: 1400px;
margin: 20px auto;
padding: 20px;
}
.ocr-row {
display: flex;
gap: 30px;
margin-top: 20px;
}
.ocr-col {
flex: 1;
}
.preview-section {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
background: #f8f9fa;
}
#preview-image {
max-width: 100%;
height: auto;
border: 1px solid #ddd;
border-radius: 4px;
margin: 10px 0;
}
#pdf-canvas {
display: none;
}
.raw-text {
font-family: 'Courier New', monospace;
white-space: pre-wrap;
background: #fff;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
font-size: 12px;
}
.form-section {
border: 1px solid #ddd;
border-radius: 4px;
padding: 20px;
background: #fff;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
.form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.auto-filled {
background-color: #fff7cc !important;
border-color: #ffc107 !important;
transition: background-color 0.5s ease, border-color 0.5s ease;
}
.form-control:focus.auto-filled {
box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25);
}
.status-indicator {
display: inline-block;
padding: 5px 15px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
margin-left: 15px;
}
.status-waiting {
background: #e9ecef;
color: #495057;
}
.status-processing {
background: #cfe2ff;
color: #084298;
}
.status-completed {
background: #d1e7dd;
color: #0f5132;
}
.status-error {
background: #f8d7da;
color: #842029;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
}
.btn-primary {
background: #0d6efd;
color: white;
}
.btn-primary:hover {
background: #0b5ed7;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5c636a;
}
.btn-success {
background: #198754;
color: white;
}
.btn-success:hover {
background: #157347;
}
.save-status {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
display: none;
}
.save-status.success {
background: #d1e7dd;
color: #0f5132;
border: 1px solid #badbcc;
display: block;
}
.save-status.error {
background: #f8d7da;
color: #842029;
border: 1px solid #f5c2c7;
display: block;
}
.header-section {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.file-input-wrapper {
display: flex;
align-items: center;
gap: 15px;
}
.toggle-wrapper {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 2px solid #e9ecef;
}
.toggle-label {
font-weight: bold;
color: #495057;
font-size: 14px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 120px;
height: 34px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
}
input:checked + .slider:before {
transform: translateX(86px);
}
.slider-text {
position: absolute;
color: white;
font-size: 11px;
font-weight: bold;
top: 50%;
transform: translateY(-50%);
transition: opacity 0.4s;
}
.slider-text-left {
left: 10px;
}
.slider-text-right {
right: 10px;
}
input:not(:checked) + .slider .slider-text-right {
opacity: 0.5;
}
input:checked + .slider .slider-text-left {
opacity: 0.5;
}
.mode-description {
font-size: 12px;
color: #6c757d;
margin-left: 10px;
}
.ai-mode-active {
color: #28a745;
font-weight: bold;
}
.js-mode-active {
color: #667eea;
font-weight: bold;
}
</style>
</head>
<body>
<?php include $_SERVER['DOCUMENT_ROOT'] . "/myheader.php"; ?>
<div class="ocr-container">
<div class="header-section">
<div>
<h3><i class="bi bi-file-earmark-text"></i> 사업자등록증 OCR</h3>
<p style="color: #6c757d; margin: 5px 0;">사업자등록증 이미지 또는 PDF를 업로드하면 자동으로 정보를 추출합니다.</p>
<p style="color: #999; font-size: 12px; margin: 5px 0;">
<i class="bi bi-info-circle"></i>
처리 시간: 10-30 소요 | 원본 이미지로 OCR 처리 + 오인식 보정 적용
</p>
<p style="color: #28a745; font-size: 11px; margin: 5px 0;">
<i class="bi bi-check-circle"></i>
인식률 향상: 원본 이미지 OCR 특수문자 제거 오타 보정 유연한 키워드 매칭 자동 입력
</p>
<p style="color: #6610f2; font-size: 11px; margin: 5px 0;">
<i class="bi bi-stars"></i>
특수문자 제거 + 오타 허용: "수 식 회 사" 주식회사 | "대 _ 표 . 자" 대표자
</p>
</div>
<div>
<a href="list.php" class="btn btn-secondary">
<i class="bi bi-list-ul"></i> 목록
</a>
</div>
</div>
<div class="toggle-wrapper">
<span class="toggle-label"><i class="bi bi-gear"></i> OCR 모드:</span>
<label class="toggle-switch">
<input type="checkbox" id="mode-toggle">
<span class="slider">
<span class="slider-text slider-text-left">JS 사용</span>
<span class="slider-text slider-text-right">AI API</span>
</span>
</label>
<span id="mode-description" class="mode-description js-mode-active">
<i class="bi bi-code-square"></i> JavaScript OCR (Tesseract.js) - 브라우저에서 직접 처리
</span>
</div>
<div class="file-input-wrapper">
<input type="file" id="file-input" class="form-control" accept="application/pdf,image/*" style="max-width: 400px;">
<span id="status" class="status-indicator status-waiting">대기중</span>
</div>
<div class="ocr-row">
<div class="ocr-col">
<div class="preview-section">
<h5><i class="bi bi-image"></i> 미리보기</h5>
<canvas id="pdf-canvas"></canvas>
<img id="preview-image" alt="파일을 업로드하면 미리보기가 표시됩니다" style="display: none;">
<div style="margin-top: 15px;">
<h6><i class="bi bi-file-text"></i> OCR 원문</h6>
<div id="raw-text" class="raw-text">OCR 결과가 여기에 표시됩니다...</div>
<p style="color: #999; font-size: 11px; margin-top: 5px;">
<i class="bi bi-lightbulb"></i>
OCR 완료 자동으로 우측 폼에 정보가 입력됩니다 (노란색 배경)
</p>
</div>
</div>
</div>
<div class="ocr-col">
<div class="form-section">
<h5><i class="bi bi-pencil-square"></i> 사업자 정보 입력</h5>
<form id="biz-form">
<div class="form-group">
<label for="biz_no">사업자등록번호 *</label>
<input type="text" class="form-control" id="biz_no" name="biz_no" placeholder="000-00-00000" required>
</div>
<div class="form-group">
<label for="company_name">상호명 *</label>
<input type="text" class="form-control" id="company_name" name="company_name" placeholder="상호명" required>
</div>
<div class="form-group">
<label for="representative">대표자명 *</label>
<input type="text" class="form-control" id="representative" name="representative" placeholder="대표자명" required>
</div>
<div class="form-group">
<label for="open_date">개업일자</label>
<input type="date" class="form-control" id="open_date" name="open_date">
</div>
<div class="form-group">
<label for="address">본점 소재지</label>
<input type="text" class="form-control" id="address" name="address" placeholder="본점 소재지">
</div>
<div class="form-group">
<label for="type">업태</label>
<input type="text" class="form-control" id="type" name="type" placeholder="업태">
</div>
<div class="form-group">
<label for="item">종목</label>
<input type="text" class="form-control" id="item" name="item" placeholder="종목">
</div>
<div class="form-group">
<label for="issue_date">발급일자</label>
<input type="date" class="form-control" id="issue_date" name="issue_date">
</div>
<div class="btn-group">
<button type="button" id="save-btn" class="btn btn-success">
<i class="bi bi-save"></i> 저장
</button>
<button type="button" id="reset-btn" class="btn btn-secondary">
<i class="bi bi-arrow-clockwise"></i> 초기화
</button>
</div>
<div id="save-status" class="save-status"></div>
</form>
</div>
</div>
</div>
</div>
<!-- PDF.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
<!-- Tesseract.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<script>
// 모든 스크립트가 로드될 때까지 대기
document.addEventListener('DOMContentLoaded', function() {
// PDF.js Worker 설정
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";
}
initializeOCR();
});
function initializeOCR() {
// DOM Elements
const fileInput = document.getElementById('file-input');
const statusEl = document.getElementById('status');
const rawTextEl = document.getElementById('raw-text');
const previewEl = document.getElementById('preview-image');
const pdfCanvas = document.getElementById('pdf-canvas');
const saveBtn = document.getElementById('save-btn');
const resetBtn = document.getElementById('reset-btn');
const saveStatus = document.getElementById('save-status');
const modeToggle = document.getElementById('mode-toggle');
const modeDescription = document.getElementById('mode-description');
// 토글 버튼 이벤트
modeToggle.addEventListener('change', function() {
if (this.checked) {
// AI API 모드
modeDescription.innerHTML = '<i class="bi bi-stars"></i> AI API (Claude) - 서버에서 AI로 처리';
modeDescription.className = 'mode-description ai-mode-active';
} else {
// JS 모드
modeDescription.innerHTML = '<i class="bi bi-code-square"></i> JavaScript OCR (Tesseract.js) - 브라우저에서 직접 처리';
modeDescription.className = 'mode-description js-mode-active';
}
});
// Utility Functions
function normalizeBizNo(value) {
const digits = (value || '').replace(/\D/g, '');
if (digits.length === 10) {
return digits.slice(0, 3) + '-' + digits.slice(3, 5) + '-' + digits.slice(5);
}
return value || '';
}
function isValidBizNo(value) {
const digits = (value || '').replace(/\D/g, '');
if (digits.length !== 10) return false;
const weights = [1, 3, 7, 1, 3, 7, 1, 3, 5];
let sum = 0;
for (let i = 0; i < 9; i++) {
sum += Number(digits[i]) * weights[i];
}
sum += Math.floor((Number(digits[8]) * 5) / 10);
const check = (10 - (sum % 10)) % 10;
return check === Number(digits[9]);
}
function toDateISO(dateStr) {
if (!dateStr) return '';
// 공백이 많이 섞인 날짜 형식 처리
// "2015 년 06 월 02 일" -> "2015-06-02"
let cleaned = dateStr.replace(/\s+/g, ' ').trim();
// 년월일 형식 추출
const patterns = [
/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*/,
/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})/,
/(\d{4})[\s.\-\/]+(\d{1,2})[\s.\-\/]+(\d{1,2})/
];
for (let pattern of patterns) {
const match = cleaned.match(pattern);
if (match) {
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
}
}
return '';
}
// OCR 텍스트 파싱 함수 (개선된 버전 - 공백 + 오타 처리)
function parseBizCert(text) {
console.log('=== OCR 원문 ===');
console.log(text);
console.log('===============');
const T = text || '';
// 공백 제거 버전 (패턴 매칭용)
const T_NO_SPACE = T.replace(/\s+/g, '');
// OCR 오인식 보정 함수 (유사 문자 치환)
const correctOCRErrors = (text) => {
return text
// 특수문자 제거 (한글/숫자 사이의 방해 요소) - 최우선 처리
.replace(/([-])\s*[_\.\-~`'"]\s*([가-힣])/g, '$1 $2') // 한글 사이 특수문자 제거
.replace(/([-])\s*[_\.\-~`'"]+\s*/g, '$1 ') // 한글 뒤 특수문자 제거
.replace(/\s*[_\.\-~`'"]+\s*([가-힣])/g, ' $1') // 한글 앞 특수문자 제거
// 한글 오인식 보정
.replace(/수\s*식\s*회\s*/g, '주식회사') // 수식회사, 수 식 회 사 → 주식회사 (공백 포함)
.replace(/수식호사/g, '주식회사') // 수식호사 → 주식회사
.replace(/주\s*식\s*호\s*/g, '주식회사') // 주식호사 → 주식회사
.replace(//g, '명') // 면 → 명 (법인면 → 법인명, 개업면월일 → 개업명월일)
.replace(/[=]/g, '표') // = → 표 (대=자 → 대표자)
.replace(/대\s*표\s*/g, '대표자') // 대표차 → 대표자
.replace(/[丨\|]/g, '') // 세로선 제거
.replace(//g, '[')
.replace(//g, ']')
.replace(/얼\s*/g, '업태') // 얼태 → 업태
.replace(/총\s*/g, '종목') // 총록 → 종목
.replace(/총\s*/g, '종목') // 총목 → 종목
.replace(/엘\s*리\s*베\s*이\s*/g, '엘리베이터')
.replace(/하\s*장\s*/g, '부장품') // 하장품 → 부장품
.replace(/의\s*장\s*/g, '의장품') // 의장품 → 의장품 (보이는 물건)
// 날짜 관련 오인식 보정
.replace(//g, '월') // 뭘 → 월 (06 뭘 → 06 월)
.replace(//g, '월') // 뭠 → 월
.replace(//g, '월') // 울 → 월
.replace(//g, '월') // 욀 → 월
.replace(//g, '일') // 융 → 일
.replace(//g, '일') // 임 → 일
.replace(//g, '일') // 읷 → 일
.replace(//g, '년') // 연 → 년
.replace(//g, '년') // 념 → 년
// 숫자 오인식 보정
.replace(/[oO]/g, '0') // o, O → 0
.replace(/[Il]/g, '1') // I, l → 1
.replace(/[Zz]/g, '2') // Z, z → 2 (선택적)
.replace(/S/g, '5') // S → 5 (선택적)
.replace(/b/g, '6'); // b → 6 (선택적)
};
// 오인식 보정된 버전
const T_CORRECTED = correctOCRErrors(T);
const T_CORRECTED_NO_SPACE = T_CORRECTED.replace(/\s+/g, '');
console.log('=== 오인식 보정 후 ===');
console.log(T_CORRECTED);
console.log('====================');
// 키워드 유연 검색 함수 (유사도 기반)
const findKeyword = (text, keywords) => {
// 공백 제거 버전에서 키워드 찾기
const textNoSpace = text.replace(/\s+/g, '');
for (let keyword of keywords) {
const keywordNoSpace = keyword.replace(/\s+/g, '');
// 정확한 매칭
if (textNoSpace.includes(keywordNoSpace)) {
return text.indexOf(keyword.split('')[0]); // 원본 텍스트에서 위치 반환
}
// 유사 매칭 (80% 이상 일치)
const similarity = calculateSimilarity(textNoSpace, keywordNoSpace);
if (similarity > 0.8) {
return 0;
}
}
return -1;
};
// 문자열 유사도 계산 (간단한 버전)
const calculateSimilarity = (str1, str2) => {
const longer = str1.length > str2.length ? str1 : str2;
const shorter = str1.length > str2.length ? str2 : str1;
if (longer.length === 0) return 1.0;
let matches = 0;
for (let i = 0; i < shorter.length; i++) {
if (longer.includes(shorter[i])) matches++;
}
return matches / longer.length;
};
// 여러 패턴을 시도하는 함수 (보정된 버전 우선)
const pickMultiple = (patterns, useNoSpace = false) => {
// 1순위: 보정된 텍스트에서 시도
const targetText1 = useNoSpace ? T_CORRECTED_NO_SPACE : T_CORRECTED;
for (let pattern of patterns) {
const match = targetText1.match(pattern);
if (match && match[1]) {
let result = match[1].trim();
result = result.replace(/[:\s=]+$/, '').trim();
result = result.replace(/\s+/g, ' ');
console.log(`✓ 패턴 매칭 성공 (보정): ${pattern} → "${result}"`);
return result;
}
}
// 2순위: 원본 텍스트에서 시도
const targetText2 = useNoSpace ? T_NO_SPACE : T;
for (let pattern of patterns) {
const match = targetText2.match(pattern);
if (match && match[1]) {
let result = match[1].trim();
result = result.replace(/[:\s=]+$/, '').trim();
result = result.replace(/\s+/g, ' ');
console.log(`✓ 패턴 매칭 성공 (원본): ${pattern} → "${result}"`);
return result;
}
}
return '';
};
// 사업자등록번호 추출 (공백 제거 버전 사용)
let bizNo = pickMultiple([
/등록번호[:\s]*(\d{3}[\-]?\d{2}[\-]?\d{5})/ui,
/번호[:\s]*(\d{3}[\-]?\d{2}[\-]?\d{5})/ui,
/(\d{3}[\-]\d{2}[\-]\d{5})/u
], true);
// 원본에서도 시도 (공백 포함)
if (!bizNo) {
bizNo = pickMultiple([
/사업자\s*등록\s*번호[^\d\n]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/ui,
/등록\s*번호[^\d\n]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/ui
]);
}
// 상호명 추출 (법인명, 단체명 포함)
let companyName = pickMultiple([
/법인명\s*\(\s*단체명\s*\)[:\s]*([^\n\r]+?)(?=\s*)/ui,
/법\s*인\s*[:\s\(]*(?:단\s*체\s*명\s*\))?[:\s]*([-힣\s]+)(?=\s*)/ui,
/상\s*호\s*?[:\s]*([-힣\s]+)(?=\s*대표자)/ui,
/상\s*[:\s]+([-힣\s]+)/ui
]);
// 공백 제거 버전에서도 시도
if (!companyName) {
companyName = pickMultiple([
/법인명[:\(]?(?:단체명\))?[:\s]*([-]+주식회사[-]+|주식회사[-]+|[-]{2,})/ui,
/상호명?[:\s]*([-]+)/ui
], true);
}
// 대표자명 추출 (유연한 키워드 매칭 + 한국 이름 패턴)
let representative = '';
const repKeywords = ['대표자명', '대표자', '대표'];
let foundRep = false;
for (let keyword of repKeywords) {
// 키워드가 포함된 라인 찾기 (공백 무시)
const keywordPattern = keyword.split('').join('[\\s]*');
const lineMatch = T_CORRECTED.match(new RegExp(`${keywordPattern}[^\\n]{0,50}`, 'ui'));
if (lineMatch) {
const line = lineMatch[0];
console.log(`대표자 키워드 발견: "${keyword}" → 라인: "${line}"`);
// 한국 이름 패턴 찾기 (2-4자 한글, 공백 포함 가능)
const namePatterns = [
// 콜론이나 공백 뒤 한글 이름 (공백 포함)
/[:\s]+([-]\s*[-]\s*[-]?\s*[-]?)\s*(?=[개발사법세]|$)/u,
// 한글 2-4자 (공백 제거 후)
/[:\s]+([-힣\s]{2,7})\s*(?=[개발사법세]|$)/u,
// 단순 한글 패턴
/[:\s]+([-힣\s]+)/u
];
for (let namePattern of namePatterns) {
const nameMatch = line.match(namePattern);
if (nameMatch) {
// 공백 제거하여 이름 추출
const name = nameMatch[1].replace(/\s+/g, '');
// 한국 이름 길이 검증 (2-4자)
if (name.length >= 2 && name.length <= 4) {
representative = name;
console.log(`✓ 대표자명 추출 성공: "${representative}" (원본: "${nameMatch[1]}")`);
foundRep = true;
break;
} else if (name.length > 4) {
// 4자 넘으면 앞 3자만 (일반적인 한국 이름)
representative = name.substring(0, 3);
console.log(`✓ 대표자명 추출 성공 (길이 조정): "${representative}" (원본: "${name}")`);
foundRep = true;
break;
}
}
}
if (foundRep) break;
}
}
// 추가 시도: 기존 패턴 사용
if (!representative) {
representative = pickMultiple([
/대\s*[=]\s*[:\s]*([-힣\s]{2,10})(?=\s*개업|\s*사업장)/ui,
/대\s*표\s*자\s*?[:\s]*([-힣\s]{2,10})/ui,
/대\s*[:\s]+([-힣\s]{2,10})/ui
]);
}
if (!representative) {
representative = pickMultiple([
/[=][:\s]*([-]{2,10})/ui,
/대표자[:\s]*([-]{2,10})/ui
], true);
}
// 최종 공백 제거 및 길이 검증
if (representative) {
representative = representative.replace(/\s+/g, '');
if (representative.length > 4) {
representative = representative.substring(0, 3);
}
}
// 개업일자 추출 (유연한 키워드 매칭)
let openDate = '';
// "개업" 키워드 근처에서 날짜 찾기
const openKeywords = ['개업연월일', '개업일자', '개업일', '개업'];
let foundOpen = false;
for (let keyword of openKeywords) {
// 키워드가 포함된 라인 찾기
const keywordPattern = keyword.split('').join('[\\s]*'); // 공백 무시
const lineMatch = T_CORRECTED.match(new RegExp(`${keywordPattern}[^\\n]{0,80}`, 'ui'));
if (lineMatch) {
const line = lineMatch[0];
console.log(`개업 키워드 발견: "${keyword}" → 라인: "${line}"`);
// 해당 라인 또는 근처에서 날짜 패턴 찾기 (유연한 패턴)
const datePatterns = [
// 년월일이 정확히 있는 경우
/(\d{4})\s*[]\s*(\d{1,2})\s*[]\s*(\d{1,2})\s*[]/,
// 년월일이 보정되어 있는 경우
/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*/,
// . / - 구분자
/(\d{4})\s*[\.\/\-]\s*(\d{1,2})\s*[\.\/\-]\s*(\d{1,2})/,
// 공백 없이 붙어있는 경우
/(\d{4})(\d{1,2})(\d{1,2})/,
/(\d{4})[\.\-\/](\d{1,2})[\.\-\/](\d{1,2})/,
// 숫자만 연속 (매우 유연)
/(\d{4})[^\d]{0,5}(\d{1,2})[^\d]{0,5}(\d{1,2})/
];
for (let datePattern of datePatterns) {
const dateMatch = line.match(datePattern);
if (dateMatch) {
const year = parseInt(dateMatch[1]);
const month = parseInt(dateMatch[2]);
const day = parseInt(dateMatch[3]);
// 유효성 검증 (합리적인 범위)
if (year >= 1900 && year <= 2100 && month >= 1 && month <= 12 && day >= 1 && day <= 31) {
openDate = `${dateMatch[1]}.${dateMatch[2]}.${dateMatch[3]}`;
console.log(`✓ 개업일자 추출 성공: "${openDate}" (패턴: ${datePattern})`);
foundOpen = true;
break;
}
}
}
if (foundOpen) break;
}
}
// 추가 시도: 전체 텍스트에서 "개업" 근처 날짜 찾기
if (!openDate) {
openDate = pickMultiple([
/개\s*[^\d\n]{0,20}(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})/ui,
/개\s*[:\s]+(\d{4}[\s년.\-]+\d{1,2}[\s월.\-]+\d{1,2})/ui
]);
}
// 주소 추출 (본점소재지, 사업장소재지)
let address = pickMultiple([
/본\s*점\s*소\s*재\s*[:\s]*([-힣\s\d\-]+?)(?=\s*사업의|$)/ui,
/사업장\s*소재지[:\s]*([-힣\s\d\-]+?)(?=\s*본점|$)/ui,
/소\s*재\s*[:\s]*([-힣\s\d\-]+)/ui
]);
// 업태 추출 (유연한 키워드 매칭)
let type = '';
const typeKeywords = ['업태'];
let foundType = false;
for (let keyword of typeKeywords) {
// 키워드가 포함된 라인 찾기 (공백 무시)
const keywordPattern = keyword.split('').join('[\\s]*');
// "업태" 키워드부터 "[" 또는 "종목"까지 또는 줄바꿈까지
const lineMatch = T_CORRECTED.match(new RegExp(`${keywordPattern}[^\\n]{0,100}`, 'ui'));
if (lineMatch) {
const line = lineMatch[0];
console.log(`업태 키워드 발견: "${keyword}" → 라인: "${line}"`);
// 업태 값 추출 패턴
const typePatterns = [
// [ 업태 | 제조업 [ 종목 형태
/업\s*[:\s]*([^\[\]]+?)(?=\s*\[)/ui,
// 업태 제조업 종목 형태
/업\s*[:\s\|]*([^\[\]\n]+?)(?=\s*종\s*)/ui,
// 업태 : 제조업 형태
/업\s*[:\s]+([-힣\s]+?)(?=\s*[\[\n]|$)/ui,
// 단순 패턴
/업\s*[:\s]+([^\n\[]+)/ui
];
for (let typePattern of typePatterns) {
const typeMatch = line.match(typePattern);
if (typeMatch) {
type = typeMatch[1].trim();
// 불필요한 기호 제거
type = type.replace(/[\|\[\]]/g, '').trim();
if (type.length > 0) {
console.log(`✓ 업태 추출 성공: "${type}"`);
foundType = true;
break;
}
}
}
if (foundType) break;
}
}
// 추가 시도: 기존 패턴 사용
if (!type) {
type = pickMultiple([
/사업의\s*종류[:\s]*\[\s*업\s*태\s*\|\s*([^\[\]]+?)\s*\[\s*종\s*/ui,
/업\s*[:\s\|]*([^\[\]종목\n]+?)(?=\s*\[?\s*종\s*|\||$)/ui,
/업태[:\s]*([^\n]+?)(?=종목|$)/ui
]);
}
// 종목 추출 (유연한 키워드 매칭)
let item = '';
const itemKeywords = ['종목'];
let foundItem = false;
for (let keyword of itemKeywords) {
// 키워드가 포함된 라인 찾기 (공백 무시)
const keywordPattern = keyword.split('').join('[\\s]*');
// "종목" 키워드부터 줄바꿈 또는 특정 키워드까지
const lineMatch = T_CORRECTED.match(new RegExp(`${keywordPattern}[^\\n]{0,100}`, 'ui'));
if (lineMatch) {
const line = lineMatch[0];
console.log(`종목 키워드 발견: "${keyword}" → 라인: "${line}"`);
// 종목 값 추출 패턴
const itemPatterns = [
// [ 종목 | 엘리베이터부장품 형태
/종\s*[:\s\|]*([^\[\]\n]+?)(?=\s*발급|\s*세금|\s*\]|$)/ui,
// 종목 : 엘리베이터부장품 형태
/종\s*[:\s]+([-힣\s]+?)(?=\s*발급|\s*세금|$)/ui,
// 단순 패턴
/종\s*[:\s]+([^\n\[]+)/ui
];
for (let itemPattern of itemPatterns) {
const itemMatch = line.match(itemPattern);
if (itemMatch) {
item = itemMatch[1].trim();
// 불필요한 기호 제거
item = item.replace(/[\|\[\]]/g, '').trim();
if (item.length > 0) {
console.log(`✓ 종목 추출 성공: "${item}"`);
foundItem = true;
break;
}
}
}
if (foundItem) break;
}
}
// 추가 시도: 기존 패턴 사용
if (!item) {
item = pickMultiple([
/\[\s*종\s*목\s*\|\s*([^\[\]]+?)(?=\s*발급|\s*세금|$)/ui,
/종\s*[:\s\|]*([^\[\]\n]+?)(?=\s*발급|\s*세금|$)/ui,
/종목[:\s]*([^\n]+?)(?=발급|$)/ui
]);
}
// 발급일자 추출 (유연한 키워드 매칭)
let issueDate = '';
// "발급" 또는 "교부" 키워드 근처에서 날짜 찾기
const issueKeywords = ['발급일자', '발급일', '교부일자', '교부일', '발급', '교부'];
let foundIssue = false;
for (let keyword of issueKeywords) {
const keywordPattern = keyword.split('').join('[\\s]*');
const lineMatch = T_CORRECTED.match(new RegExp(`${keywordPattern}[^\\n]{0,80}`, 'ui'));
if (lineMatch) {
const line = lineMatch[0];
console.log(`발급 키워드 발견: "${keyword}" → 라인: "${line}"`);
// 유연한 날짜 패턴
const datePatterns = [
/(\d{4})\s*[]\s*(\d{1,2})\s*[]\s*(\d{1,2})\s*[]/,
/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*/,
/(\d{4})\s*[\.\/\-]\s*(\d{1,2})\s*[\.\/\-]\s*(\d{1,2})/,
/(\d{4})(\d{1,2})(\d{1,2})/,
/(\d{4})[\.\-\/](\d{1,2})[\.\-\/](\d{1,2})/,
// 숫자만 연속 (매우 유연)
/(\d{4})[^\d]{0,5}(\d{1,2})[^\d]{0,5}(\d{1,2})/
];
for (let datePattern of datePatterns) {
const dateMatch = line.match(datePattern);
if (dateMatch) {
const year = parseInt(dateMatch[1]);
const month = parseInt(dateMatch[2]);
const day = parseInt(dateMatch[3]);
// 유효성 검증
if (year >= 1900 && year <= 2100 && month >= 1 && month <= 12 && day >= 1 && day <= 31) {
issueDate = `${dateMatch[1]}.${dateMatch[2]}.${dateMatch[3]}`;
console.log(`✓ 발급일자 추출 성공: "${issueDate}" (패턴: ${datePattern})`);
foundIssue = true;
break;
}
}
}
if (foundIssue) break;
}
}
// 추가 시도: 세무서 근처 날짜 또는 마지막 날짜
if (!issueDate) {
// 세무서 앞 날짜
const taxMatch = T_CORRECTED.match(/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*[^\n]*세무서/ui);
if (taxMatch) {
issueDate = `${taxMatch[1]}.${taxMatch[2]}.${taxMatch[3]}`;
console.log(`✓ 발급일자 추출 (세무서 근처): "${issueDate}"`);
} else {
// 텍스트의 마지막 날짜를 발급일로 추정
const dates = T.match(/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*/g);
if (dates && dates.length > 0) {
issueDate = dates[dates.length - 1];
console.log(`✓ 발급일자 추출 (마지막 날짜): "${issueDate}"`);
}
}
}
// 텍스트 정리 함수 (불필요한 공백 제거, 특수문자 정리)
const cleanText = (text) => {
if (!text) return '';
return text
.replace(/\s+/g, ' ') // 연속 공백을 하나로
.replace(/\s*([,.])\s*/g, '$1 ') // 구두점 뒤 공백 정리
.replace(/\s+$/, '') // 끝 공백 제거
.replace(/^\s+/, '') // 시작 공백 제거
.replace(/[\_\=\|]+/g, '') // 불필요한 특수문자 제거
.trim();
};
// 한글 단어 내 공백 제거 (예: "미 래 기 업" -> "미래기업")
const removeKoreanSpaces = (text) => {
if (!text) return '';
let result = text;
// 반복적으로 한글 사이의 단일 공백 제거
let prevResult;
do {
prevResult = result;
result = result.replace(/([-])\s+([-])/g, '$1$2');
} while (result !== prevResult);
return result;
};
// 정규화
bizNo = normalizeBizNo(bizNo);
openDate = toDateISO(openDate);
issueDate = toDateISO(issueDate);
// 텍스트 정리
companyName = removeKoreanSpaces(cleanText(companyName));
representative = removeKoreanSpaces(cleanText(representative));
address = cleanText(address);
type = cleanText(type);
item = cleanText(item);
// 추출 결과 로깅
const result = {
biz_no: bizNo,
company_name: companyName,
representative: representative,
open_date: openDate,
address: address,
type: type,
item: item,
issue_date: issueDate
};
console.log('=== 추출 결과 (최종) ===');
console.log('사업자번호:', bizNo || '❌ 추출 실패');
console.log('상호명:', companyName || '❌ 추출 실패');
console.log('대표자:', representative || '❌ 추출 실패');
console.log('개업일자:', openDate || '❌ 추출 실패');
console.log('주소:', address || '❌ 추출 실패');
console.log('업태:', type || '❌ 추출 실패');
console.log('종목:', item || '❌ 추출 실패');
console.log('발급일자:', issueDate || '❌ 추출 실패');
console.log('========================');
// 성공률 계산
const totalFields = 8;
const successCount = [bizNo, companyName, representative, openDate, address, type, item, issueDate]
.filter(v => v).length;
const successRate = Math.round((successCount / totalFields) * 100);
console.log(`✓ 추출 성공률: ${successCount}/${totalFields} (${successRate}%)`);
console.log('========================');
return result;
}
// 이미지 전처리 (OCR 인식률 향상)
function preprocessImage(canvas) {
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// 1. 그레이스케일 변환
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = avg; // R
data[i + 1] = avg; // G
data[i + 2] = avg; // B
}
// 2. 대비 증가 (Contrast Enhancement)
const factor = 1.5; // 대비 계수 (1.0 = 원본, 값이 클수록 대비 증가)
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128));
data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128));
data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128));
}
// 3. 이진화 (Binarization) - Otsu's method 간소화 버전
// 밝은 배경의 문서에 적합
const threshold = 128; // 임계값
for (let i = 0; i < data.length; i += 4) {
const brightness = data[i];
const value = brightness > threshold ? 255 : 0;
data[i] = value;
data[i + 1] = value;
data[i + 2] = value;
}
ctx.putImageData(imageData, 0, 0);
return canvas;
}
// PDF를 이미지로 변환 (원본 해상도 유지)
async function pdfToImage(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(1);
// 원본 크기로 렌더링
const viewport = page.getViewport({ scale: 2.0 });
const canvas = pdfCanvas;
const context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: context, viewport: viewport }).promise;
// 원본 이미지 그대로 반환 (전처리 제거)
return canvas.toDataURL('image/png');
}
// 일반 이미지 원본 유지 (전처리 제거)
async function preprocessImageFile(imageDataURL) {
// 원본 이미지를 그대로 반환
return Promise.resolve(imageDataURL);
}
// AI API를 사용한 OCR 처리
async function runAIOCR(imageDataURL, rawText) {
updateStatus('AI API 호출중...', 'processing');
try {
// 현재 페이지와 같은 디렉토리의 claude_api.php
const currentPath = window.location.pathname;
const apiUrl = currentPath.substring(0, currentPath.lastIndexOf('/')) + '/claude_api.php';
console.log('Current path:', currentPath);
console.log('API URL:', apiUrl);
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
image: imageDataURL,
raw_text: rawText
})
});
console.log('Response status:', response.status);
console.log('Response URL:', response.url);
if (!response.ok) {
const errorText = await response.text();
console.error('HTTP Error:', response.status, errorText);
throw new Error(`Claude API 호출 실패 (HTTP ${response.status})\nURL: ${response.url}`);
}
const result = await response.json();
console.log('API Response:', result);
if (!result.ok) {
console.error('API Error:', result);
throw new Error(result.error || 'AI API 호출 실패');
}
updateStatus('AI 분석 완료', 'completed');
return result.data;
} catch (error) {
console.error('AI API Error:', error);
updateStatus('AI API 실패: ' + error.message, 'error');
throw error;
}
}
// OCR 실행 (최적화된 설정)
async function runOCR(imageDataURL) {
updateStatus('OCR 처리중...', 'processing');
try {
// Tesseract.js 실행 (한글 최적화 설정)
const { data } = await Tesseract.recognize(
imageDataURL,
'kor+eng', // 한글 + 영어 동시 인식
{
logger: (m) => {
if (m.status === 'recognizing text') {
updateStatus(`인식중... ${Math.round(m.progress * 100)}%`, 'processing');
}
if (m.status === 'loading lang') {
updateStatus('언어팩 다운로드중...', 'processing');
}
if (m.status === 'initializing tesseract') {
updateStatus('OCR 엔진 초기화중...', 'processing');
}
},
langPath: 'https://tessdata.projectnaptha.com/4.0.0',
// Tesseract 파라미터 최적화
tessedit_pageseg_mode: '1', // Automatic page segmentation with OSD
tessedit_char_whitelist: '', // 모든 문자 허용
preserve_interword_spaces: '1' // 단어 간 공백 유지
}
);
console.log('=== OCR 신뢰도 정보 ===');
console.log('평균 신뢰도:', data.confidence + '%');
console.log('===================');
updateStatus('OCR 완료', 'completed');
return data.text || '';
} catch (error) {
console.error('OCR Error:', error);
updateStatus('OCR 실패', 'error');
throw error;
}
}
// 상태 업데이트
function updateStatus(message, status) {
statusEl.textContent = message;
statusEl.className = 'status-indicator status-' + status;
}
// 폼 필드 자동입력 (개선된 버전)
function fillFormFields(fields) {
let filledCount = 0;
let totalFields = Object.keys(fields).length;
console.log('=== 필드 자동입력 시작 ===');
Object.keys(fields).forEach(key => {
const element = document.getElementById(key);
const value = fields[key];
console.log(`${key}: "${value}" → ${value ? '입력' : '빈값'}`);
if (element) {
if (value) {
element.value = value;
element.classList.add('auto-filled');
filledCount++;
// 애니메이션 효과
element.style.transition = 'background-color 0.5s';
setTimeout(() => {
element.style.backgroundColor = '#fff7cc';
}, 50);
} else {
element.value = '';
element.classList.remove('auto-filled');
element.style.backgroundColor = '';
}
}
});
console.log(`총 ${filledCount}/${totalFields} 필드 입력 완료`);
console.log('=========================');
// 사업자번호 유효성 검증
const bizNo = document.getElementById('biz_no').value;
if (bizNo) {
if (isValidBizNo(bizNo)) {
updateStatus(`OCR 완료 (${filledCount}개 필드 자동입력) ✓`, 'completed');
} else {
updateStatus(`OCR 완료 (사업자번호 검증 실패 - 수동 확인 필요)`, 'error');
}
} else {
if (filledCount > 0) {
updateStatus(`OCR 완료 (${filledCount}개 필드 자동입력)`, 'completed');
} else {
updateStatus('OCR 완료 (자동 추출 실패 - 수동 입력 필요)', 'error');
}
}
}
// 파일 선택 이벤트
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
rawTextEl.textContent = 'OCR 처리중 ...';
previewEl.style.display = 'none';
updateStatus('파일 처리중 ...', 'processing');
// 이전 입력 필드 초기화
document.querySelectorAll('.form-control').forEach(el => {
el.value = '';
el.classList.remove('auto-filled');
});
let imageDataURL = null;
const useAI = modeToggle.checked;
try {
if (file.type === 'application/pdf') {
updateStatus('PDF 변환중...', 'processing');
imageDataURL = await pdfToImage(file);
previewEl.src = imageDataURL;
previewEl.style.display = 'block';
} else if (file.type.startsWith('image/')) {
updateStatus('원본 이미지 로딩중...', 'processing');
const originalDataURL = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.readAsDataURL(file);
});
// 원본 이미지 그대로 사용
imageDataURL = await preprocessImageFile(originalDataURL);
previewEl.src = originalDataURL; // 미리보기도 원본으로
previewEl.style.display = 'block';
} else {
updateStatus('지원하지 않는 파일 형식', 'error');
return;
}
if (useAI) {
// AI API 모드
updateStatus('Tesseract.js로 텍스트 추출중...', 'processing');
const text = await runOCR(imageDataURL);
rawTextEl.textContent = text;
// AI API로 데이터 추출
updateStatus('AI API로 데이터 분석중...', 'processing');
const fields = await runAIOCR(imageDataURL, text);
console.log('=== AI API 추출 결과 ===');
console.log(fields);
console.log('======================');
fillFormFields(fields);
} else {
// JavaScript OCR 모드
const text = await runOCR(imageDataURL);
rawTextEl.textContent = text;
// 파싱 후 폼 자동입력
const fields = parseBizCert(text);
fillFormFields(fields);
}
} catch (error) {
console.error('처리 오류:', error);
rawTextEl.textContent = '오류가 발생했습니다: ' + error.message;
updateStatus('파일 처리 실패', 'error');
}
});
// 저장 버튼
saveBtn.addEventListener('click', async () => {
const formData = {};
const form = document.getElementById('biz-form');
// 필수 필드 검증
const requiredFields = ['biz_no', 'company_name', 'representative'];
let isValid = true;
requiredFields.forEach(field => {
const element = document.getElementById(field);
if (!element.value.trim()) {
element.style.borderColor = 'red';
isValid = false;
} else {
element.style.borderColor = '#ddd';
}
});
if (!isValid) {
showSaveStatus('필수 항목을 입력해주세요.', 'error');
return;
}
// 폼 데이터 수집
new FormData(form).forEach((value, key) => {
formData[key] = value;
});
formData['raw_text'] = rawTextEl.textContent;
try {
const response = await fetch('save_biz.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.ok) {
showSaveStatus(`저장 성공 완료 (ID: ${result.id})`, 'success');
setTimeout(() => {
if (confirm('목록으로 이동하시겠습니까?')) {
location.href = 'list.php';
}
}, 1000);
} else {
showSaveStatus('저장 실패: ' + (result.error || 'Unknown error'), 'error');
}
} catch (error) {
console.error('저장 오류:', error);
showSaveStatus('저장 중 오류가 발생했습니다.', 'error');
}
});
// 초기화 버튼
resetBtn.addEventListener('click', () => {
if (confirm('입력한 내용을 초기화하시겠습니까?')) {
document.getElementById('biz-form').reset();
document.querySelectorAll('.form-control').forEach(el => {
el.classList.remove('auto-filled');
el.style.borderColor = '#ddd';
});
rawTextEl.textContent = 'OCR 결과가 여기에 표시됩니다...';
previewEl.style.display = 'none';
updateStatus('대기중', 'waiting');
saveStatus.style.display = 'none';
fileInput.value = '';
}
});
// 저장 상태 표시
function showSaveStatus(message, type) {
saveStatus.textContent = message;
saveStatus.className = 'save-status ' + type;
saveStatus.style.display = 'block';
if (type === 'success') {
setTimeout(() => {
saveStatus.style.display = 'none';
}, 3000);
}
}
} // initializeOCR 함수 종료
// 페이지 로드 완료 시 loader 숨기기
window.addEventListener('load', function() {
const loadingOverlay = document.getElementById('loadingOverlay');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
});
</script>
</body>
</html>