스캔한 한 장을, 확인할 수 있는 마크다운으로
이미지를 Markdown으로 변환하고 data.values 본문, path 기반 cells, review.flagged, 원본 좌표로 결과를 확인하는 구현 가이드.
스캔한 문서를 마크다운으로 바꾸려는 이유는 대개 둘입니다. 그리고 이 둘이 원하는 게 조금 다릅니다.
하나는 게시입니다. 위키나 문서 사이트, 저장소에 올리고 싶고, 그때 제목은 제목인 채로 남아 있어야 합니다. 다른 하나는 모델에 먹이는 것입니다. 많은 LLM 파이프라인이 마크다운을 전제로 하는 이유는 문법이 구조를 실어 나르기 때문입니다. ## 은 여기가 절의 경계라고 말해 주고, 파이프 표는 이 셀들이 같은 행이라고 말해 줍니다. 평문에서는 그 정보가 사라집니다.
실패하는 방식도 같습니다. 제목이 문단으로 뭉개지면 문서 사이트는 벽 한 장이 되고, 검색용 청크는 엉뚱한 데서 잘립니다. 그리고 두 용도 모두, 돌아오는 것은 대개 마크다운 문자열 하나뿐이라 이 줄이 페이지 어디서 왔는지 물어볼 방법이 없습니다.
"레이아웃 보존" 이 실제로 지켜야 하는 것
쓸 만한 마크다운 변환은 서로 독립적인 네 가지 판단을 제대로 해야 합니다.
- 읽기순서 — 다단 조판에서 문장이 뒤섞이지 않아야 합니다. 素의 OCR 출력이 가장 먼저 무너지는 지점으로, 엔진은 사람이 읽는 순서가 아니라 검출 순서로 문단을 뱉습니다.
- 블록 종류 — 이 줄이 제목인지, 목록 항목인지, 인용인지, 그냥 문단인지. 스캔에서는 글자 크기만으로 판단할 수 없습니다.
- 표 구조 — 어떤 셀이 같은 행인지, 어느 행이 헤더인지, 셀이 두 줄로 접혔을 때 어떻게 되는지.
- 흘리지 않을 것 — 아무도 눈치채지 못하는 실패입니다. 문단이 조용히 사라져도 마크다운은 멀쩡해 보입니다.
네 번째가 가장 고약합니다. 페이지의 5% 를 흘린 변환 결과는 읽기에는 완벽하게 읽히니까요.
한 번 호출하고, 네 응답 층으로 나눠 받기
POST /ocr/markdown은 래스터 이미지 한 장을 URL 또는 base64로 받습니다. 구조·좌표·검토 UI가 필요하면 기본값인 includeElements: true를 사용하세요. 게시할 Markdown과 검토용 메타데이터가 응답에서 분리됩니다. 요청 한도와 전체 필드는 API 문서에서 확인할 수 있습니다.
curl -X POST https://api.space-ocr.com/ocr/markdown \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/report.jpg",
"imageType": "url",
"includeElements": true
}'{
"status": "success",
"data": {
"values": {
"markdown": "# 분기 보고서\n\n매출은 전년 동기 대비 증가했다.\n\n| 항목 | 금액 |\n| --- | --- |\n| 매출 | 12,000 |",
"elements": [
{
"type": "heading",
"level": 1,
"text": "분기 보고서"
},
{
"type": "paragraph",
"text": "매출은 전년 동기 대비 증가했다."
},
{
"type": "table",
"rows": 2,
"cols": 2,
"cells": [
{
"row": 0,
"col": 0,
"header": true,
"text": "항목"
},
{
"row": 0,
"col": 1,
"header": true,
"text": "금액"
},
{
"row": 1,
"col": 0,
"header": false,
"text": "매출"
},
{
"row": 1,
"col": 1,
"header": false,
"text": "12,000"
}
]
}
]
},
"cells": {
"elements[0]": {
"box": {
"xmin": 36,
"ymin": 21,
"xmax": 314,
"ymax": 39
},
"quad": [
{
"x": 36,
"y": 21
},
{
"x": 314,
"y": 21
},
{
"x": 314,
"y": 39
},
{
"x": 36,
"y": 39
}
],
"verified": true,
"review": null,
"evidence": {
"text_match": true,
"source": "token_id"
}
},
"elements[1]": {
"box": {
"xmin": 36,
"ymin": 51,
"xmax": 544,
"ymax": 68
},
"quad": [
{
"x": 36,
"y": 51
},
{
"x": 544,
"y": 51
},
{
"x": 544,
"y": 68
},
{
"x": 36,
"y": 68
}
],
"verified": true,
"review": null,
"evidence": {
"text_match": true,
"source": "token_id"
}
},
"elements[2]": {
"box": {
"xmin": 36,
"ymin": 86,
"xmax": 387,
"ymax": 137
},
"quad": [
{
"x": 36,
"y": 86
},
{
"x": 387,
"y": 86
},
{
"x": 387,
"y": 137
},
{
"x": 36,
"y": 137
}
],
"verified": null,
"review": null,
"evidence": {}
},
"elements[2].cells[0]": {
"box": {
"xmin": 36,
"ymin": 86,
"xmax": 212,
"ymax": 111
},
"quad": [
{
"x": 36,
"y": 86
},
{
"x": 212,
"y": 86
},
{
"x": 212,
"y": 111
},
{
"x": 36,
"y": 111
}
],
"verified": true,
"review": null,
"evidence": {
"text_match": true,
"source": "token_id"
}
},
"elements[2].cells[1]": {
"box": {
"xmin": 212,
"ymin": 86,
"xmax": 387,
"ymax": 111
},
"quad": [
{
"x": 212,
"y": 86
},
{
"x": 387,
"y": 86
},
{
"x": 387,
"y": 111
},
{
"x": 212,
"y": 111
}
],
"verified": false,
"review": {
"reasons": [
"text_mismatch"
]
},
"evidence": {
"text_match": false,
"source": "token_id",
"ocr_confidence": 0.71
}
},
"elements[2].cells[2]": {
"box": {
"xmin": 36,
"ymin": 111,
"xmax": 212,
"ymax": 137
},
"quad": [
{
"x": 36,
"y": 111
},
{
"x": 212,
"y": 111
},
{
"x": 212,
"y": 137
},
{
"x": 36,
"y": 137
}
],
"verified": true,
"review": null,
"evidence": {
"text_match": true,
"source": "token_id"
}
},
"elements[2].cells[3]": {
"box": {
"xmin": 212,
"ymin": 111,
"xmax": 387,
"ymax": 137
},
"quad": [
{
"x": 212,
"y": 111
},
{
"x": 387,
"y": 111
},
{
"x": 387,
"y": 137
},
{
"x": 212,
"y": 137
}
],
"verified": true,
"review": null,
"evidence": {
"text_match": true,
"source": "token_id"
}
}
},
"review": {
"unit": "element",
"total": 6,
"boxed": 6,
"verified": 5,
"flagged": [
{
"path": "elements[2].cells[1]",
"reasons": [
"text_mismatch"
]
}
],
"by_reason": {
"text_mismatch": 1
},
"coverage": {
"recovered_blocks": 0,
"vision_tokens": 40,
"tokens_claimed": 40,
"token_coverage": 1
}
},
"image": {
"width": 1654,
"height": 2339
}
}
}data.values.markdown은 완성된 문자열이고, data.values.elements는 내용만 담은 요소 배열입니다. 제목, 문단, 목록 항목, 인용문, 코드 블록, 구분선, 표를 표현합니다. 표는 rows, cols, 내용 전용 cells[]를 가집니다.
좌표는 요소 안이 아니라 flat data.cells 맵에 있습니다. elements[0]은 첫 요소, elements[2].cells[1]은 세 번째 요소가 표일 때 그 표의 두 번째 셀입니다. data.review.flagged[].path도 같은 문법이므로 검토 항목에서 바로 조회할 수 있습니다.
const { data } = body;
for (const flag of data.review.flagged) {
const cell = data.cells?.[flag.path];
if (!cell) continue;
console.log(flag.path, flag.reasons, cell.review?.reasons);
drawQuad(cell.quad.map(({ x, y }) => ({
x: (x / 1000) * data.image.width,
y: (y / 1000) * data.image.height,
})));
}검토 목록을 원본 오버레이로 연결하기
임의의 점수 임계값을 만들지 말고 data.review.flagged에서 시작합니다. 각 항목의 path와 reasons를 읽고 data.cells[path]의 review.reasons를 표시하세요. 기울어진 외곽선은 quad, 축 기준 계산은 box를 사용합니다.
두 좌표 모두 0~1000 정규화 값입니다. 방향 보정과 서버 처리가 반영된 실제 읽기 프레임인 data.image.width와 height로 픽셀을 환산합니다. verified: false는 검토 사유가 있다는 뜻이고, true는 사유 없이 대조가 실행된 경우, null은 사유는 없지만 대조 대상도 없었던 경우입니다. evidence는 진단 근거이지 선택된 글자가 업무 의미까지 맞다는 보장은 아닙니다.
누락 여부도 명시적으로 확인할 수 있습니다. data.review.coverage에는 vision_tokens, tokens_claimed, token_coverage, recovered_blocks가 있습니다. 어떤 요소도 가져가지 않은 token은 cell의 evidence.source가 "unclaimed_tokens"인 마지막 문단으로 회수됩니다. 완전성 확인에 사용하되 API가 정의하지 않은 합격 임계값을 만들지는 마세요.
elements를 끄는 경우
includeElements의 기본값은 true입니다. 완성된 data.values.markdown 문자열만 필요할 때만 false로 설정하세요. 그러면 data.values.elements와 요소별 data.cells가 생략되므로 해당 응답으로 요소 단위 하이라이트를 만들 수 없습니다.
파이프라인에서 쓰는 법
RAG에서는 data.values.elements를 제목 경계로 청킹하고 path를 함께 저장하면 인용에서 원본 영역을 다시 열 수 있습니다. 문서 사이트에는 data.values.markdown을 게시하고 elements·cells·review·image는 검토용 sidecar로 보관하세요.
구조가 필요 없다면 읽기 순서 원문 텍스트 OCR을, 오버레이 구현은 원본 좌표로 OCR 검증하기를 참고하세요.
이미지를 검토 가능한 Markdown으로 변환하는 방법
- 한 페이지 준비하기URL 또는 base64 래스터 이미지를 준비합니다. PDF는 API 호출 전에 페이지별 이미지로 변환합니다.
- 요소 포함해 요청하기구조·원본 좌표·사람 검토가 필요하면 includeElements: true로 POST /ocr/markdown을 호출합니다.
- 응답 층 나눠 저장하기게시에는 data.values.markdown, 구조 처리에는 data.values.elements를 사용하고 cells·review·image를 검토용으로 보관합니다.
- flagged path 확인하기data.review.flagged를 순회하며 data.cells[flag.path]를 열고 사유와 box·quad를 data.image 기준으로 표시합니다.
- 완전성 검토 후 게시하기data.review.coverage와 recovered blocks를 확인하고 필요한 검토를 끝낸 뒤 Markdown을 게시하거나 청킹합니다.