질문 : deepseek-OCR 이 공개 프로그램을 어떻게 사용할 수 있나?

답변 : 
딥시크(DeepSeek)의 공개 OCR 모델 **DeepSeek-OCR**를 로컬/서버에서 바로 돌릴 수 있어요. 핵심은 두 가지 실행 경로(vLLM 또는 Hugging Face Transformers) 중 하나를 고르는 것.

### 1) 이게 뭐고, 뭘 잘하나

* 문서/표/차트까지 포함한 **레이아웃 보존형 OCR → Markdown 변환**이 강점. 프롬프트로 “Convert the document to markdown” 같은 지시를 주면 구조화해서 뽑아줘요. ([GitHub][1])
* 오픈소스(MIT) + 공개 가중치(Hugging Face). 최근 **vLLM 업스트림 공식 지원**도 들어갔습니다. ([GitHub][1])

---

## 빠른 설치 (CUDA 11.8, PyTorch 2.6 기준)

```bash
# 0) 필수: NVIDIA 드라이버 + CUDA 11.8 런타임 환경
# 1) 클론
git clone https://github.com/deepseek-ai/DeepSeek-OCR.git
cd DeepSeek-OCR

# 2) 가상환경 (conda 예시)
conda create -n deepseek-ocr python=3.12.9 -y
conda activate deepseek-ocr

# 3) 패키지 (vLLM 경로)
pip install torch==2.6.0 torchvision==0.21.0 torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu118
# vLLM 0.8.5 wheel 설치 (리드미의 .whl 사용) + requirements
pip install vllm-0.8.5+cu118-cp38-abi3-manylinux1_x86_64.whl
pip install -r requirements.txt
pip install flash-attn==2.7.3 --no-build-isolation
```

> 위 버전 고정과 설치 순서는 프로젝트 리드미의 권장 스펙입니다. ([GitHub][1])

---

## 실행 ①: vLLM 경로 (성능/동시성 유리)

```bash
cd DeepSeek-OCR-master/DeepSeek-OCR-vllm
# config.py 에 INPUT_PATH / OUTPUT_PATH 등 경로 설정
# 1) 단일/다중 이미지 OCR (스트리밍 출력)
python run_dpsk_ocr_image.py
# 2) PDF 대량 처리 (예: A100-40G 기준 ~2500 tokens/s 표기)
python run_dpsk_ocr_pdf.py
```

* vLLM 최신 야간 빌드로도 구동 가능 (업스트림 공식 지원). 예시 스니펫은 HF 모델카드 참고. ([GitHub][1])

---

## 실행 ②: Transformers 경로 (간단 통합/커스터마이즈 용이)

```python
from transformers import AutoModel, AutoTokenizer
import torch, os
os.environ["CUDA_VISIBLE_DEVICES"] = '0'
m = 'deepseek-ai/DeepSeek-OCR'
tok = AutoTokenizer.from_pretrained(m, trust_remote_code=True)
model = AutoModel.from_pretrained(
    m, _attn_implementation='flash_attention_2',
    trust_remote_code=True, use_safetensors=True
).eval().cuda().to(torch.bfloat16)

prompt = "<image>\n<|grounding|>Convert the document to markdown. "
image_file = "sample.jpg"
output_dir = "./out"

res = model.infer(tok, prompt=prompt, image_file=image_file,
                  output_path=output_dir, base_size=1024,
                  image_size=640, crop_mode=True,
                  save_results=True, test_compress=True)
```

* 모델/토크나이저는 HF에서 받아옵니다. **Tiny/Small/Base/Large** 입력 해상도 프리셋도 제공. ([Hugging Face][2])

---

## 주요 프롬프트 예시

* 문서 → 마크다운:
  `<image>\n<|grounding|>Convert the document to markdown.`
* 단순 문자만:
  `<image>\nFree OCR.`
* 피겨/차트 파싱:
  `<image>\nParse the figure.`
  리드미에 여러 프롬프트가 예시로 포함되어 있어요. ([GitHub][1])

---

## PDF 배치 처리·API로 감싸기

* **공식 스크립트**: `run_dpsk_ocr_pdf.py` 로 폴더 단위 처리. ([GitHub][1])
* **빠른 웹/API 템플릿**(비공식): FastAPI+React로 감싼 샘플들이 여럿 있어요.
  예: “PDF→Markdown 변환” REST API, 또는 데모 웹앱. ([GitHub][3])
* **러스트 경량 서버**(CPU/Metal/초기 CUDA): OpenAI 호환 HTTP 서버/CLI 포함. 로컬 파이프라인 구성에 유용. ([GitHub][4])

---

## 리소스/성능 메모

* HF 카드 기준 모델은 약 **3B 파라미터, BF16**, GPU 구동 전제 예시가 제공됩니다(Transformers 섹션). CPU도 이론상 가능하나 실사용 속도는 크게 떨어질 수 있어요. vLLM 경로는 대용량 처리에 유리. ([Hugging Face][2])
* 리드미에는 **A100-40G에서 PDF 처리 체감치(토큰/s)** 예시가 실려 있습니다. ([GitHub][1])

---

## 보안/정책 유의(특히 기업 환경)

일부 공공기관·기업은 DeepSeek 계열 모델 사용을 제한하기도 합니다. 예컨대 **미 상무부 일부 국·실국은 2025-03-17 공지로 DeepSeek 사용 금지**를 안내했습니다. 회사 정책과 고객사 보안요건을 먼저 확인하세요. ([Reuters][5])

---

## 공식 문서/레퍼런스

* GitHub(설치·스크립트·프롬프트·PDF 처리): ([GitHub][1])
* Hugging Face 모델 카드(버전 고정·vLLM 예시): ([Hugging Face][2])
* vLLM 가이드(오프라인 배치 레시피): ([VLLM Documentation][6])
* 연구/아티클(컨텍스트 압축 아이디어): ([arXiv][7])

---

### 다음 선택지

* 운영 관점: **배치 파이프라인(PDF 폴더→MD/JSON)** 또는 **REST API** 중 하나로 고정.
* 프롬프트/후처리: 표/수식/도형은 “Convert to markdown” + 후처리(표 파서, 수식 라텍스 보정) 조합이 안정적.
* 우리 스택(Nginx/Apache + PHP/Laravel + Jenkins)에서라면: **별도 GPU 호스트 + FastAPI 서비스**로 세워 두고, Admin/API에서 비동기 잡 큐로 호출하는 구조가 깔끔합니다.

원하시면 **PDF 폴더 일괄 변환 스크립트**(입력/출력 디렉토리, 동시성, 실패 리트라이 포함)나 **FastAPI 라우터 샘플**까지 바로 짜드릴게요.

[1]: https://github.com/deepseek-ai/DeepSeek-OCR "GitHub - deepseek-ai/DeepSeek-OCR: Contexts Optical Compression"
[2]: https://huggingface.co/deepseek-ai/DeepSeek-OCR "deepseek-ai/DeepSeek-OCR · Hugging Face"
[3]: https://github.com/Bogdanovich77/DeekSeek-OCR---Dockerized-API?utm_source=chatgpt.com "Bogdanovich77/DeekSeek-OCR---Dockerized-API"
[4]: https://github.com/TimmyOVO/deepseek-ocr.rs?utm_source=chatgpt.com "TimmyOVO/deepseek-ocr.rs"
[5]: https://www.reuters.com/technology/artificial-intelligence/us-commerce-department-bureaus-ban-chinas-deepseek-government-devices-sources-2025-03-17/?utm_source=chatgpt.com "US Commerce department bureaus ban China's DeepSeek on government devices, sources say"
[6]: https://docs.vllm.ai/projects/recipes/en/latest/DeepSeek/DeepSeek-OCR.html?utm_source=chatgpt.com "DeepSeek-OCR Usage Guide - vLLM Recipes"
[7]: https://arxiv.org/html/2510.18234v1?utm_source=chatgpt.com "DeepSeek-OCR: Contexts Optical Compression"




질문 :
사업자등록증 pdf를 업로드 해서 AI를 활용한 OCR 기술로 그 사업자 등록증의 형태에 맞게 웹이나 앱에서 이 입력부분을 자동으로 처리하는 것을 만들려고 한다. 만약 php와 자바스크립트를 이용해서 이 로직구현이 가능할까? 추후 python등의 서버를 구비해서 API형태로 만들고 싶다.


AI 답변 :
충분히 됩니다. 카페24 **웹호스팅(PHP 7.3 / MySQL 8.0)** 기준으로, 서버에 바이너리를 깔 수 없으니 **브라우저에서 Tesseract.js + pdf.js**로 OCR을 돌리고, **추출 결과만** PHP로 저장하는 구조가 가장 현실적이에요. 그대로 가져다 쓸 수 있게 “파일 단위”로 정리했습니다.

---

# 0) DB 테이블 (MySQL 8.0)

```sql
CREATE TABLE biz_cert (
  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  biz_no       VARCHAR(20)   NOT NULL,      -- 000-00-00000
  company_name VARCHAR(255)  NOT NULL,
  representative VARCHAR(100) NOT NULL,
  open_date    DATE          NULL,
  address      TEXT          NULL,
  type         VARCHAR(255)  NULL,
  item         VARCHAR(255)  NULL,
  issue_date   DATE          NULL,
  raw_text     LONGTEXT      NULL,          -- 원문 텍스트(디버그용)
  created_at   TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_biz_no (biz_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

---

# 1) `/config/db.php` (PDO 연결 공통)

```php
<?php
// /config/db.php
define('DB_HOST', 'localhost');
define('DB_NAME', 'YOUR_DB_NAME');
define('DB_USER', 'YOUR_DB_USER');
define('DB_PASS', 'YOUR_DB_PASS');
define('DB_CHARSET', 'utf8mb4');

function db_connect(): PDO {
  $dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset='.DB_CHARSET;
  $opt = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  ];
  return new PDO($dsn, DB_USER, DB_PASS, $opt);
}
```

---

# 2) `/public/index.html` (업로드→브라우저 OCR→자동채움→저장)

```html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>사업자등록증 OCR (MVP, PHP7.3/MySQL8)</title>
<style>
  body{font:14px/1.4 system-ui,Segoe UI,Apple SD Gothic Neo,Malgun Gothic}
  .row{display:flex;gap:24px}
  .col{flex:1}
  #preview{max-width:100%;border:1px solid #ddd}
  .hl{background:#fff7cc}
  .mono{font-family:ui-monospace,Consolas,monospace}
</style>
</head>
<body>
<h2>사업자등록증 OCR (AI 없이, 브라우저 Tesseract.js)</h2>

<input id="file" type="file" accept="application/pdf,image/*" />
<span id="status">대기중</span>
<div class="row" style="margin-top:16px">
  <div class="col">
    <canvas id="pdf-canvas" style="display:none"></canvas>
    <img id="preview" alt="미리보기" />
    <pre id="raw" class="mono" style="white-space:pre-wrap;background:#f7f7f7;padding:8px"></pre>
  </div>
  <div class="col">
    <form id="biz-form">
      <p><input class="hl" name="biz_no" placeholder="사업자등록번호 (000-00-00000)" style="width:100%"></p>
      <p><input class="hl" name="company_name" placeholder="상호" style="width:100%"></p>
      <p><input class="hl" name="representative" placeholder="대표자" style="width:100%"></p>
      <p><input class="hl" name="open_date" placeholder="개업연월일 (YYYY-MM-DD)" style="width:100%"></p>
      <p><input class="hl" name="address" placeholder="사업장 소재지" style="width:100%"></p>
      <p><input class="hl" name="type" placeholder="업태" style="width:100%"></p>
      <p><input class="hl" name="item" placeholder="종목" style="width:100%"></p>
      <p><input name="issue_date" placeholder="발급일자 (선택)" style="width:100%"></p>
      <button type="button" id="save">저장</button>
    </form>
    <div id="save-status"></div>
  </div>
</div>

<!-- pdf.js (브라우저에서 PDF → canvas 렌더링) -->
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
<script>pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";</script>

<!-- Tesseract.js (브라우저 OCR) -->
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>

<script>
const $ = (q)=>document.querySelector(q);
const statusEl = $('#status');
const rawEl = $('#raw');
const previewEl = $('#preview');
const pdfCanvas = $('#pdf-canvas');

function normalizeBiz(v){
  const d=(v||'').replace(/\D/g,'');
  if(d.length===10) return d.slice(0,3)+'-'+d.slice(3,5)+'-'+d.slice(5);
  return v||'';
}
function isValidBizNo(v){
  const d=(v||'').replace(/\D/g,''); if(d.length!==10) return false;
  const w=[1,3,7,1,3,7,1,3,5]; let sum=0;
  for(let i=0;i<9;i++) sum += Number(d[i])*w[i];
  sum += Math.floor((Number(d[8])*5)/10);
  const check=(10-(sum%10))%10; return check===Number(d[9]);
}
function toDateISO(s){
  if(!s) return '';
  const d=s.replace(/[년월.\-\/\s]/g,'-').replace(/-+/g,'-').replace(/-$/, '');
  const m=d.match(/(\d{4})-(\d{1,2})-(\d{1,2})/); 
  return m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : '';
}

// 간단 파서 (OCR 텍스트 → 필드)
function parseBiz(text){
  const T=text||'';
  const pick=(re)=>{const m=T.match(re); return m?m[1].trim():''};

  // 앵커 기반
  let biz = pick(/사업자등록번호[^\d]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/u);
  let company = pick(/상\s*호[^\S\r\n]*[:：]?\s*([^\r\n]+)/u);
  let rep = pick(/대표자[^\S\r\n]*[:：]?\s*([^\r\n]+)/u);
  let open = pick(/개업[^\r\n]*(\d{4}[.\-년]\s*\d{1,2}[.\-월]?\s*\d{1,2})/u);
  let addr = pick(/사업장\s*소재지[^\S\r\n]*[:：]?\s*([^\r\n]+(?:\r?\n[^\r\n]+)?)/u);
  let type = pick(/업태[^\S\r\n]*[:：]?\s*([^\r\n]+)/u);
  let item = pick(/종목[^\S\r\n]*[:：]?\s*([^\r\n]+)/u);
  let issue = pick(/발급일자[^\d]*(\d{4}[.\-\/]\s*\d{1,2}[.\-\/]?\s*\d{1,2})/u);

  biz = normalizeBiz(biz);
  open = toDateISO(open);
  issue = toDateISO(issue);

  return { biz_no: biz, company_name: company, representative: rep,
           open_date: open, address: addr, type, item, issue_date: issue };
}

// PDF → canvas → dataURL
async function pdfFirstPageToDataURL(file){
  const buf = await file.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({data:buf}).promise;
  const page = await pdf.getPage(1);
  const viewport = page.getViewport({scale: 2.0});
  const canvas = pdfCanvas; const ctx = canvas.getContext('2d');
  canvas.width = viewport.width; canvas.height = viewport.height;
  await page.render({canvasContext:ctx, viewport}).promise;
  return canvas.toDataURL('image/png');
}

async function runOCR(imageDataURL){
  statusEl.textContent = 'OCR 시작...';
  const { data } = await Tesseract.recognize(
    imageDataURL,
    'kor+eng',
    {
      logger: m => { if(m.status==='recognizing text'){ statusEl.textContent = `인식중... ${Math.round(m.progress*100)}%`; } },
      // 학습 데이터 경로 (공개 CDN)
      langPath: 'https://tessdata.projectnaptha.com/4.0.0'
    }
  );
  statusEl.textContent = 'OCR 완료';
  return data.text || '';
}

$('#file').addEventListener('change', async (e)=>{
  const f = e.target.files[0]; if(!f) return;
  rawEl.textContent = ''; statusEl.textContent='준비중...';

  let imageDataURL=null;

  if(f.type==='application/pdf'){
    imageDataURL = await pdfFirstPageToDataURL(f);     // 1페이지만 예시
    previewEl.src = imageDataURL;
  } else if(f.type.startsWith('image/')){
    imageDataURL = URL.createObjectURL(f);
    previewEl.src = imageDataURL;
  } else {
    statusEl.textContent = '지원하지 않는 형식';
    return;
  }

  const text = await runOCR(imageDataURL);
  rawEl.textContent = text;

  const fields = parseBiz(text);
  for(const k in fields){
    const el=document.querySelector(`[name="${k}"]`);
    if(el) el.value = fields[k] || '';
  }

  // 즉시 유효성 힌트
  const biz = document.querySelector('[name="biz_no"]').value;
  if(biz && !isValidBizNo(biz)) statusEl.textContent = '참고: 사업자번호 체크섬 불일치(수정 검토)';
});

$('#save').addEventListener('click', async ()=>{
  const form = $('#biz-form'); const obj = {};
  new FormData(form).forEach((v,k)=>obj[k]=v);
  obj['raw_text'] = rawEl.textContent;

  const res = await fetch('/save_biz.php', {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify(obj)
  });
  const data = await res.json();
  $('#save-status').textContent = data.ok ? `저장 완료 (id=${data.id})` : `실패: ${data.error||'unknown'}`;
});
</script>
</body>
</html>
```

> 포인트
>
> * **PDF도 브라우저에서 1페이지만 렌더** → 이미지처럼 OCR. (여러 페이지면 반복 처리)
> * **언어팩**: `kor+eng`, `langPath`는 공개 CDN 사용.
> * **정규식 파싱**은 앵커(“사업자등록번호”, “상호”… ) 기반이라 양식이 조금 달라도 견딥니다.

---

# 3) `/save_biz.php` (추출 결과 저장 API)

```php
<?php
// /save_biz.php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__.'/config/db.php';

function normalize_biz($v){
  $d = preg_replace('/\D/','',$v??'');
  return (strlen($d)===10) ? substr($d,0,3).'-'.substr($d,3,2).'-'.substr($d,5) : ($v??'');
}
function valid_biz_no($s){
  $d = preg_replace('/\D/','',$s??'');
  if(strlen($d)!=10) return false;
  $w=[1,3,7,1,3,7,1,3,5]; $sum=0;
  for($i=0;$i<9;$i++){ $sum += intval($d[$i])*$w[$i]; }
  $sum += intdiv(intval($d[8])*5,10);
  $check = (10 - ($sum % 10)) % 10;
  return $check == intval($d[9]);
}
function to_date($s){
  if(!$s) return null;
  $d = preg_replace('/[년월.\-\/\s]+/','-',$s);
  if(preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/',$d,$m)){
    return sprintf('%04d-%02d-%02d',$m[1],$m[2],$m[3]);
  }
  return null;
}

$input = json_decode(file_get_contents('php://input'), true) ?: [];

$biz_no = normalize_biz($input['biz_no'] ?? '');
$company_name = trim($input['company_name'] ?? '');
$representative = trim($input['representative'] ?? '');
$open_date = to_date($input['open_date'] ?? '');
$address = trim($input['address'] ?? '');
$type = trim($input['type'] ?? '');
$item = trim($input['item'] ?? '');
$issue_date = to_date($input['issue_date'] ?? '');
$raw_text = $input['raw_text'] ?? '';

if(!$biz_no || !$company_name){
  echo json_encode(['ok'=>false,'error'=>'필수값 누락(biz_no/company_name)']); exit;
}

try{
  $pdo = db_connect();
  $stmt = $pdo->prepare("
    INSERT INTO biz_cert
    (biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
    VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
  ");
  $stmt->execute([
    ':biz_no'=>$biz_no,
    ':company_name'=>$company_name,
    ':representative'=>$representative,
    ':open_date'=>$open_date,
    ':address'=>$address,
    ':type'=>$type,
    ':item'=>$item,
    ':issue_date'=>$issue_date,
    ':raw_text'=>$raw_text,
  ]);
  echo json_encode(['ok'=>true,'id'=>$pdo->lastInsertId(),'valid_biz_no'=>valid_biz_no($biz_no)]);
} catch(Throwable $e){
  http_response_code(500);
  echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
}
```

---

# 4) `/list.php` (최근 20건 확인용)

```php
<?php
// /list.php
header('Content-Type: text/html; charset=utf-8');
require_once __DIR__.'/config/db.php';
$pdo = db_connect();
$rows = $pdo->query("SELECT id,biz_no,company_name,representative,open_date,created_at FROM biz_cert ORDER BY id DESC LIMIT 20")->fetchAll();
?>
<!doctype html><meta charset="utf-8">
<h3>최근 20건</h3>
<table border="1" cellpadding="6" cellspacing="0">
<tr><th>ID</th><th>사업자번호</th><th>상호</th><th>대표자</th><th>개업일</th><th>저장일</th></tr>
<?php foreach($rows as $r): ?>
<tr>
  <td><?=htmlspecialchars($r['id'])?></td>
  <td><?=htmlspecialchars($r['biz_no'])?></td>
  <td><?=htmlspecialchars($r['company_name'])?></td>
  <td><?=htmlspecialchars($r['representative'])?></td>
  <td><?=htmlspecialchars($r['open_date'])?></td>
  <td><?=htmlspecialchars($r['created_at'])?></td>
</tr>
<?php endforeach; ?>
</table>
```

---

## 운영 팁 (카페24 웹호스팅 한정)

* **서버 exec 미사용**: Tesseract, Poppler 같은 바이너리 설치 불가하므로 브라우저 OCR이 현실적.
* **용량/속도**: PDF가 크면 1페이지만 먼저 처리 → “추가 페이지 인식” 버튼으로 확장.
* **언어팩 캐시**: Tesseract.js가 언어 데이터(약 수~수십 MB)를 내려받습니다. CDN 캐시 이점이 큼.
* **정확도 올리기**: PDF 렌더 스케일을 `2.0→2.5~3.0`으로, 이미지면 `<img>`를 `<canvas>`로 리샘플 후 OCR.
* **보정 UX**: 자동 채움 후 노란 배경(샘플처럼 `.hl`) + 탭 이동 → 저장.
* **보안**: 저장 전 **숫자 마스킹**(예: `대표자 주민번호 일부`)이 필요하면 프런트/백 모두에서 처리.

---

## 다음 단계(나중에 고도화)

* Python API(DeepSeek-OCR/PaddleOCR) 별도 서버 → 같은 방식으로 **/save_biz.php** 앞단에서 교체 호출.
* 앵커 크롭 후 2차 OCR(속도/정확도↑), 주소 정규화, 체크섬 실패 시 재인식 루틴 등.

필요하면 **여러 페이지 PDF 지원**, **라라벨 컨트롤러 버전**, **Docker-Compose 모식도**, **Nginx 리버스 프록시 샘플**까지 이어서 드릴게요.


질문 : 
카페24에서 웹호스팅을 해서 사용하는 사이트가 있는데, 위의 라이브러리를 사용해서 직접 구현이 가능하게 단계별 코드를 만들어줘. php 7.3 mysql 8.0 기준으로 보면됩니다.

답변 :
충분히 됩니다. 카페24 **웹호스팅(PHP 7.3 / MySQL 8.0)** 기준으로, 서버에 바이너리를 깔 수 없으니 **브라우저에서 Tesseract.js + pdf.js**로 OCR을 돌리고, **추출 결과만** PHP로 저장하는 구조가 가장 현실적이에요. 그대로 가져다 쓸 수 있게 “파일 단위”로 정리했습니다.

---

# 0) DB 테이블 (MySQL 8.0)

```sql
CREATE TABLE biz_cert (
  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  biz_no       VARCHAR(20)   NOT NULL,      -- 000-00-00000
  company_name VARCHAR(255)  NOT NULL,
  representative VARCHAR(100) NOT NULL,
  open_date    DATE          NULL,
  address      TEXT          NULL,
  type         VARCHAR(255)  NULL,
  item         VARCHAR(255)  NULL,
  issue_date   DATE          NULL,
  raw_text     LONGTEXT      NULL,          -- 원문 텍스트(디버그용)
  created_at   TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_biz_no (biz_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

---

# 1) `/config/db.php` (PDO 연결 공통)

```php
<?php
// /config/db.php
define('DB_HOST', 'localhost');
define('DB_NAME', 'YOUR_DB_NAME');
define('DB_USER', 'YOUR_DB_USER');
define('DB_PASS', 'YOUR_DB_PASS');
define('DB_CHARSET', 'utf8mb4');

function db_connect(): PDO {
  $dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset='.DB_CHARSET;
  $opt = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  ];
  return new PDO($dsn, DB_USER, DB_PASS, $opt);
}
```

---

# 2) `/public/index.html` (업로드→브라우저 OCR→자동채움→저장)

```html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>사업자등록증 OCR (MVP, PHP7.3/MySQL8)</title>
<style>
  body{font:14px/1.4 system-ui,Segoe UI,Apple SD Gothic Neo,Malgun Gothic}
  .row{display:flex;gap:24px}
  .col{flex:1}
  #preview{max-width:100%;border:1px solid #ddd}
  .hl{background:#fff7cc}
  .mono{font-family:ui-monospace,Consolas,monospace}
</style>
</head>
<body>
<h2>사업자등록증 OCR (AI 없이, 브라우저 Tesseract.js)</h2>

<input id="file" type="file" accept="application/pdf,image/*" />
<span id="status">대기중</span>
<div class="row" style="margin-top:16px">
  <div class="col">
    <canvas id="pdf-canvas" style="display:none"></canvas>
    <img id="preview" alt="미리보기" />
    <pre id="raw" class="mono" style="white-space:pre-wrap;background:#f7f7f7;padding:8px"></pre>
  </div>
  <div class="col">
    <form id="biz-form">
      <p><input class="hl" name="biz_no" placeholder="사업자등록번호 (000-00-00000)" style="width:100%"></p>
      <p><input class="hl" name="company_name" placeholder="상호" style="width:100%"></p>
      <p><input class="hl" name="representative" placeholder="대표자" style="width:100%"></p>
      <p><input class="hl" name="open_date" placeholder="개업연월일 (YYYY-MM-DD)" style="width:100%"></p>
      <p><input class="hl" name="address" placeholder="사업장 소재지" style="width:100%"></p>
      <p><input class="hl" name="type" placeholder="업태" style="width:100%"></p>
      <p><input class="hl" name="item" placeholder="종목" style="width:100%"></p>
      <p><input name="issue_date" placeholder="발급일자 (선택)" style="width:100%"></p>
      <button type="button" id="save">저장</button>
    </form>
    <div id="save-status"></div>
  </div>
</div>

<!-- pdf.js (브라우저에서 PDF → canvas 렌더링) -->
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
<script>pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";</script>

<!-- Tesseract.js (브라우저 OCR) -->
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>

<script>
const $ = (q)=>document.querySelector(q);
const statusEl = $('#status');
const rawEl = $('#raw');
const previewEl = $('#preview');
const pdfCanvas = $('#pdf-canvas');

function normalizeBiz(v){
  const d=(v||'').replace(/\D/g,'');
  if(d.length===10) return d.slice(0,3)+'-'+d.slice(3,5)+'-'+d.slice(5);
  return v||'';
}
function isValidBizNo(v){
  const d=(v||'').replace(/\D/g,''); if(d.length!==10) return false;
  const w=[1,3,7,1,3,7,1,3,5]; let sum=0;
  for(let i=0;i<9;i++) sum += Number(d[i])*w[i];
  sum += Math.floor((Number(d[8])*5)/10);
  const check=(10-(sum%10))%10; return check===Number(d[9]);
}
function toDateISO(s){
  if(!s) return '';
  const d=s.replace(/[년월.\-\/\s]/g,'-').replace(/-+/g,'-').replace(/-$/, '');
  const m=d.match(/(\d{4})-(\d{1,2})-(\d{1,2})/); 
  return m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : '';
}

// 간단 파서 (OCR 텍스트 → 필드)
function parseBiz(text){
  const T=text||'';
  const pick=(re)=>{const m=T.match(re); return m?m[1].trim():''};

  // 앵커 기반
  let biz = pick(/사업자등록번호[^\d]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/u);
  let company = pick(/상\s*호[^\S\r\n]*[:：]?\s*([^\r\n]+)/u);
  let rep = pick(/대표자[^\S\r\n]*[:：]?\s*([^\r\n]+)/u);
  let open = pick(/개업[^\r\n]*(\d{4}[.\-년]\s*\d{1,2}[.\-월]?\s*\d{1,2})/u);
  let addr = pick(/사업장\s*소재지[^\S\r\n]*[:：]?\s*([^\r\n]+(?:\r?\n[^\r\n]+)?)/u);
  let type = pick(/업태[^\S\r\n]*[:：]?\s*([^\r\n]+)/u);
  let item = pick(/종목[^\S\r\n]*[:：]?\s*([^\r\n]+)/u);
  let issue = pick(/발급일자[^\d]*(\d{4}[.\-\/]\s*\d{1,2}[.\-\/]?\s*\d{1,2})/u);

  biz = normalizeBiz(biz);
  open = toDateISO(open);
  issue = toDateISO(issue);

  return { biz_no: biz, company_name: company, representative: rep,
           open_date: open, address: addr, type, item, issue_date: issue };
}

// PDF → canvas → dataURL
async function pdfFirstPageToDataURL(file){
  const buf = await file.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({data:buf}).promise;
  const page = await pdf.getPage(1);
  const viewport = page.getViewport({scale: 2.0});
  const canvas = pdfCanvas; const ctx = canvas.getContext('2d');
  canvas.width = viewport.width; canvas.height = viewport.height;
  await page.render({canvasContext:ctx, viewport}).promise;
  return canvas.toDataURL('image/png');
}

async function runOCR(imageDataURL){
  statusEl.textContent = 'OCR 시작...';
  const { data } = await Tesseract.recognize(
    imageDataURL,
    'kor+eng',
    {
      logger: m => { if(m.status==='recognizing text'){ statusEl.textContent = `인식중... ${Math.round(m.progress*100)}%`; } },
      // 학습 데이터 경로 (공개 CDN)
      langPath: 'https://tessdata.projectnaptha.com/4.0.0'
    }
  );
  statusEl.textContent = 'OCR 완료';
  return data.text || '';
}

$('#file').addEventListener('change', async (e)=>{
  const f = e.target.files[0]; if(!f) return;
  rawEl.textContent = ''; statusEl.textContent='준비중...';

  let imageDataURL=null;

  if(f.type==='application/pdf'){
    imageDataURL = await pdfFirstPageToDataURL(f);     // 1페이지만 예시
    previewEl.src = imageDataURL;
  } else if(f.type.startsWith('image/')){
    imageDataURL = URL.createObjectURL(f);
    previewEl.src = imageDataURL;
  } else {
    statusEl.textContent = '지원하지 않는 형식';
    return;
  }

  const text = await runOCR(imageDataURL);
  rawEl.textContent = text;

  const fields = parseBiz(text);
  for(const k in fields){
    const el=document.querySelector(`[name="${k}"]`);
    if(el) el.value = fields[k] || '';
  }

  // 즉시 유효성 힌트
  const biz = document.querySelector('[name="biz_no"]').value;
  if(biz && !isValidBizNo(biz)) statusEl.textContent = '참고: 사업자번호 체크섬 불일치(수정 검토)';
});

$('#save').addEventListener('click', async ()=>{
  const form = $('#biz-form'); const obj = {};
  new FormData(form).forEach((v,k)=>obj[k]=v);
  obj['raw_text'] = rawEl.textContent;

  const res = await fetch('/save_biz.php', {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify(obj)
  });
  const data = await res.json();
  $('#save-status').textContent = data.ok ? `저장 완료 (id=${data.id})` : `실패: ${data.error||'unknown'}`;
});
</script>
</body>
</html>
```

> 포인트
>
> * **PDF도 브라우저에서 1페이지만 렌더** → 이미지처럼 OCR. (여러 페이지면 반복 처리)
> * **언어팩**: `kor+eng`, `langPath`는 공개 CDN 사용.
> * **정규식 파싱**은 앵커(“사업자등록번호”, “상호”… ) 기반이라 양식이 조금 달라도 견딥니다.

---

# 3) `/save_biz.php` (추출 결과 저장 API)

```php
<?php
// /save_biz.php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__.'/config/db.php';

function normalize_biz($v){
  $d = preg_replace('/\D/','',$v??'');
  return (strlen($d)===10) ? substr($d,0,3).'-'.substr($d,3,2).'-'.substr($d,5) : ($v??'');
}
function valid_biz_no($s){
  $d = preg_replace('/\D/','',$s??'');
  if(strlen($d)!=10) return false;
  $w=[1,3,7,1,3,7,1,3,5]; $sum=0;
  for($i=0;$i<9;$i++){ $sum += intval($d[$i])*$w[$i]; }
  $sum += intdiv(intval($d[8])*5,10);
  $check = (10 - ($sum % 10)) % 10;
  return $check == intval($d[9]);
}
function to_date($s){
  if(!$s) return null;
  $d = preg_replace('/[년월.\-\/\s]+/','-',$s);
  if(preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/',$d,$m)){
    return sprintf('%04d-%02d-%02d',$m[1],$m[2],$m[3]);
  }
  return null;
}

$input = json_decode(file_get_contents('php://input'), true) ?: [];

$biz_no = normalize_biz($input['biz_no'] ?? '');
$company_name = trim($input['company_name'] ?? '');
$representative = trim($input['representative'] ?? '');
$open_date = to_date($input['open_date'] ?? '');
$address = trim($input['address'] ?? '');
$type = trim($input['type'] ?? '');
$item = trim($input['item'] ?? '');
$issue_date = to_date($input['issue_date'] ?? '');
$raw_text = $input['raw_text'] ?? '';

if(!$biz_no || !$company_name){
  echo json_encode(['ok'=>false,'error'=>'필수값 누락(biz_no/company_name)']); exit;
}

try{
  $pdo = db_connect();
  $stmt = $pdo->prepare("
    INSERT INTO biz_cert
    (biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
    VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
  ");
  $stmt->execute([
    ':biz_no'=>$biz_no,
    ':company_name'=>$company_name,
    ':representative'=>$representative,
    ':open_date'=>$open_date,
    ':address'=>$address,
    ':type'=>$type,
    ':item'=>$item,
    ':issue_date'=>$issue_date,
    ':raw_text'=>$raw_text,
  ]);
  echo json_encode(['ok'=>true,'id'=>$pdo->lastInsertId(),'valid_biz_no'=>valid_biz_no($biz_no)]);
} catch(Throwable $e){
  http_response_code(500);
  echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
}
```

---

# 4) `/list.php` (최근 20건 확인용)

```php
<?php
// /list.php
header('Content-Type: text/html; charset=utf-8');
require_once __DIR__.'/config/db.php';
$pdo = db_connect();
$rows = $pdo->query("SELECT id,biz_no,company_name,representative,open_date,created_at FROM biz_cert ORDER BY id DESC LIMIT 20")->fetchAll();
?>
<!doctype html><meta charset="utf-8">
<h3>최근 20건</h3>
<table border="1" cellpadding="6" cellspacing="0">
<tr><th>ID</th><th>사업자번호</th><th>상호</th><th>대표자</th><th>개업일</th><th>저장일</th></tr>
<?php foreach($rows as $r): ?>
<tr>
  <td><?=htmlspecialchars($r['id'])?></td>
  <td><?=htmlspecialchars($r['biz_no'])?></td>
  <td><?=htmlspecialchars($r['company_name'])?></td>
  <td><?=htmlspecialchars($r['representative'])?></td>
  <td><?=htmlspecialchars($r['open_date'])?></td>
  <td><?=htmlspecialchars($r['created_at'])?></td>
</tr>
<?php endforeach; ?>
</table>
```

---

## 운영 팁 (카페24 웹호스팅 한정)

* **서버 exec 미사용**: Tesseract, Poppler 같은 바이너리 설치 불가하므로 브라우저 OCR이 현실적.
* **용량/속도**: PDF가 크면 1페이지만 먼저 처리 → “추가 페이지 인식” 버튼으로 확장.
* **언어팩 캐시**: Tesseract.js가 언어 데이터(약 수~수십 MB)를 내려받습니다. CDN 캐시 이점이 큼.
* **정확도 올리기**: PDF 렌더 스케일을 `2.0→2.5~3.0`으로, 이미지면 `<img>`를 `<canvas>`로 리샘플 후 OCR.
* **보정 UX**: 자동 채움 후 노란 배경(샘플처럼 `.hl`) + 탭 이동 → 저장.
* **보안**: 저장 전 **숫자 마스킹**(예: `대표자 주민번호 일부`)이 필요하면 프런트/백 모두에서 처리.

---

## 다음 단계(나중에 고도화)

* Python API(DeepSeek-OCR/PaddleOCR) 별도 서버 → 같은 방식으로 **/save_biz.php** 앞단에서 교체 호출.
* 앵커 크롭 후 2차 OCR(속도/정확도↑), 주소 정규화, 체크섬 실패 시 재인식 루틴 등.

필요하면 **여러 페이지 PDF 지원**, **라라벨 컨트롤러 버전**, **Docker-Compose 모식도**, **Nginx 리버스 프록시 샘플**까지 이어서 드릴게요.
