space ocr
가이드아티클요금문서
developer

스캔한 한 장을, 확인할 수 있는 마크다운으로

이미지를 Markdown으로 변환하고 data.values 본문, path 기반 cells, review.flagged, 원본 좌표로 결과를 확인하는 구현 가이드.

8 분 분량· 2026-08-31

스캔한 문서를 마크다운으로 바꾸려는 이유는 대개 둘입니다. 그리고 이 둘이 원하는 게 조금 다릅니다.

하나는 게시입니다. 위키나 문서 사이트, 저장소에 올리고 싶고, 그때 제목은 제목인 채로 남아 있어야 합니다. 다른 하나는 모델에 먹이는 것입니다. 많은 LLM 파이프라인이 마크다운을 전제로 하는 이유는 문법이 구조를 실어 나르기 때문입니다. ## 은 여기가 절의 경계라고 말해 주고, 파이프 표는 이 셀들이 같은 행이라고 말해 줍니다. 평문에서는 그 정보가 사라집니다.

실패하는 방식도 같습니다. 제목이 문단으로 뭉개지면 문서 사이트는 벽 한 장이 되고, 검색용 청크는 엉뚱한 데서 잘립니다. 그리고 두 용도 모두, 돌아오는 것은 대개 마크다운 문자열 하나뿐이라 이 줄이 페이지 어디서 왔는지 물어볼 방법이 없습니다.

"레이아웃 보존" 이 실제로 지켜야 하는 것

쓸 만한 마크다운 변환은 서로 독립적인 네 가지 판단을 제대로 해야 합니다.

  1. 읽기순서 — 다단 조판에서 문장이 뒤섞이지 않아야 합니다. 素의 OCR 출력이 가장 먼저 무너지는 지점으로, 엔진은 사람이 읽는 순서가 아니라 검출 순서로 문단을 뱉습니다.
  2. 블록 종류 — 이 줄이 제목인지, 목록 항목인지, 인용인지, 그냥 문단인지. 스캔에서는 글자 크기만으로 판단할 수 없습니다.
  3. 표 구조 — 어떤 셀이 같은 행인지, 어느 행이 헤더인지, 셀이 두 줄로 접혔을 때 어떻게 되는지.
  4. 흘리지 않을 것 — 아무도 눈치채지 못하는 실패입니다. 문단이 조용히 사라져도 마크다운은 멀쩡해 보입니다.

네 번째가 가장 고약합니다. 페이지의 5% 를 흘린 변환 결과는 읽기에는 완벽하게 읽히니까요.

한 번 호출하고, 네 응답 층으로 나눠 받기

POST /ocr/markdown은 래스터 이미지 한 장을 URL 또는 base64로 받습니다. 구조·좌표·검토 UI가 필요하면 기본값인 includeElements: true를 사용하세요. 게시할 Markdown과 검토용 메타데이터가 응답에서 분리됩니다. 요청 한도와 전체 필드는 API 문서에서 확인할 수 있습니다.

1
2
3
4
5
6
7
8
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
  }'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
{
  "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도 같은 문법이므로 검토 항목에서 바로 조회할 수 있습니다.

review-markdown-elements.js
1
2
3
4
5
6
7
8
9
10
11
12
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에서 시작합니다. 각 항목의 pathreasons를 읽고 data.cells[path]review.reasons를 표시하세요. 기울어진 외곽선은 quad, 축 기준 계산은 box를 사용합니다.

두 좌표 모두 0~1000 정규화 값입니다. 방향 보정과 서버 처리가 반영된 실제 읽기 프레임인 data.image.widthheight로 픽셀을 환산합니다. verified: false는 검토 사유가 있다는 뜻이고, true는 사유 없이 대조가 실행된 경우, null은 사유는 없지만 대조 대상도 없었던 경우입니다. evidence는 진단 근거이지 선택된 글자가 업무 의미까지 맞다는 보장은 아닙니다.

✓ Verified

누락 여부도 명시적으로 확인할 수 있습니다. 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으로 변환하는 방법

  1. 한 페이지 준비하기
    URL 또는 base64 래스터 이미지를 준비합니다. PDF는 API 호출 전에 페이지별 이미지로 변환합니다.
  2. 요소 포함해 요청하기
    구조·원본 좌표·사람 검토가 필요하면 includeElements: true로 POST /ocr/markdown을 호출합니다.
  3. 응답 층 나눠 저장하기
    게시에는 data.values.markdown, 구조 처리에는 data.values.elements를 사용하고 cells·review·image를 검토용으로 보관합니다.
  4. flagged path 확인하기
    data.review.flagged를 순회하며 data.cells[flag.path]를 열고 사유와 box·quad를 data.image 기준으로 표시합니다.
  5. 완전성 검토 후 게시하기
    data.review.coverage와 recovered blocks를 확인하고 필요한 검토를 끝낸 뒤 Markdown을 게시하거나 청킹합니다.
Markdown과 요소는 어디에 반환되나요?
완성된 문자열은 data.values.markdown에 있습니다. includeElements가 켜져 있으면 내용 전용 요소는 data.values.elements, 좌표와 검증 메타데이터는 flat data.cells에 반환됩니다.
검토 대상 표 셀의 원본 위치는 어떻게 찾나요?
data.review.flagged의 path를 data.cells[flag.path]에 그대로 사용하세요. 표 셀 path는 elements[2].cells[1]처럼 생겼습니다. data.image 프레임에 quad 또는 box를 그립니다.
includeElements: false면 무엇이 달라지나요?
기본값은 true입니다. false여도 완성된 Markdown은 반환되지만 data.values.elements와 요소별 data.cells는 생략됩니다.
PDF를 API에 바로 보낼 수 있나요?
OCR API는 JPEG, PNG, GIF, BMP, TIFF, WebP 래스터 이미지를 받습니다. PDF는 페이지마다 이미지로 변환한 뒤 호출하세요. 웹 앱은 드롭한 PDF를 이미지로 변환할 수 있습니다.
비용은 어떻게 확인하나요?
현재 페이지당 credit과 plan 정책은 /pricing에서 확인하세요. 처리 실패는 과금되지 않으며 export 정책은 OCR 호출과 별도로 확인해야 합니다.
관련 글