조용히 실패하는 것들 6 — «검색되는 PDF»가 한글은 한 글자도 검색하지 못했어요
홈 화면은 «검색되는 문서로 만들어 드려요»라고 말하고 있었습니다. 그런데 코드 깊은 곳에는 «코드값 256 위는 공백으로»라는 한 줄이 있었어요. 한글은 U+AC00부터 시작합니다. 주 시장의 언어가 통째로 공백이 되고 있었습니다.
Pocket PDF의 홈 화면은 이렇게 말합니다.
찍은 문서가 검색되는 깨끗한 새 문서로
빈 화면도 같은 말을 해요 — ‘자동 보정 후 검색 가능한 PDF로’.
그런데 한국어 계약서를 스캔해서 만든 PDF에서 ‘계약서’를 검색하면, 아무것도 나오지 않았습니다. 한 글자도요.
앱은 멀쩡히 돌았고, PDF도 멀쩡히 만들어졌고, 화면에는 글자가 또렷이 보였어요. 그래서 아무도 몰랐습니다. 이 앱의 주 시장이 한국인데도요.
먼저, OCR이 뭘 하는 건지부터
스캔한 문서는 글자처럼 보이는 그림입니다. 사람 눈에는 ‘계약서’지만, 컴퓨터에게는 픽셀 덩어리예요. 그림 속 글자는 검색도, 복사도 안 됩니다.
OCR(Optical Character Recognition, 광학 문자 인식)은 그 그림을 읽어서 ‘어느 자리에 어떤 글자가 있다’는 데이터로 바꾸는 일입니다. “이 사각형 안의 픽셀은 ‘계’라는 글자다” — 이걸 문서 전체에 대해 알아내는 거예요.
그런데 OCR을 돌렸다고 끝이 아닙니다. 그 결과를 PDF 안에 다시 넣어야 검색이 되거든요. 여기에 쓰는 게 보이지 않는 글자층(invisible text layer)입니다.
- 눈에 보이는 것: 스캔한 그림 그대로
- 그 위(정확히는 같은 자리)에: 투명한 진짜 글자
PDF 뷰어에서 스캔본의 글자를 드래그하면 선택이 되는 문서들, 다 이 구조입니다. 그림 위에 얹힌 투명한 글자를 선택하고 있는 거예요.
ML Kit — 기기 안에서 읽습니다
Pocket PDF의 OCR은 구글 ML Kit의 Text Recognition을 씁니다. 서버로 아무것도 보내지 않고 기기 안에서 돌아요 — ‘문서가 밖으로 나가지 않는다’가 이 앱의 약속이라, 온디바이스는 선택이 아니라 전제였습니다.
적용은 생각보다 단순합니다. 인식기를 하나 만들고,
private val recognizer = TextRecognition.getClient(
KoreanTextRecognizerOptions.Builder().build()
)
비트맵을 넣으면 결과가 나옵니다.
val inputImage = InputImage.fromBitmap(bitmap, 0)
val visionText = recognizer.process(inputImage).await()
여기서 중요한 게 두 가지예요.
하나, 인식기는 언어별입니다. KoreanTextRecognizerOptions는 한글과 라틴을
읽습니다. 일본어·중국어를 읽으려면 다른 인식기를 써야 하고, 모델도 각자 딸려 와요.
“어떤 글자가 나올 수 있는가”는 인식기가 정합니다 — 이 사실이 뒤에서 다시 나옵니다.
둘, 결과는 글자만이 아니라 자리를 들고 옵니다. ML Kit은 결과를
블록 → 줄 → 낱말(element)의 세 층으로 주는데, 층마다 boundingBox —
그 글자가 그림의 어느 사각형에 있었는지 — 가 붙어 있어요.
이 사각형이 글자층의 전부입니다. 낱말 ‘보증금’이 그림의 (320, 480)에서 90×40 픽셀이었다면, PDF에서도 그림이 놓인 자리에 맞춰 환산한 그 사각형에 투명한 ‘보증금’을 그립니다. 그래야 검색해서 하이라이트되는 자리와 눈에 보이는 글자가 겹쳐요.
contentStream.setRenderingMode(RenderingMode.NEITHER) // 그리지도, 외곽선도 없이
contentStream.beginText()
contentStream.setFont(font, height * 0.85f)
contentStream.newLineAtOffset(left, bottom)
contentStream.showText(text)
contentStream.endText()
RenderingMode.NEITHER가 ‘보이지 않는’의 정체입니다. 글자를 배치는 하되
잉크를 칠하지 않아요.
그런데 폰트가 문제였습니다
PDF에 글자를 그리려면 폰트가 필요합니다. 여기서 편한 길이 하나 있어요 — PDF 규격에는 표준 14 폰트라는 게 있어서, Helvetica 같은 폰트는 파일을 싣지 않고도 이름만으로 쓸 수 있습니다.
글자층은 그 편한 길로 그려지고 있었습니다.
val font = PDType1Font.HELVETICA
공짜에는 대가가 있습니다. 표준 14 폰트는 WinAnsi(Latin-1) 인코딩 밖을 아예 못 그립니다. 코드값 255까지 — 라틴 알파벳과 서유럽 문자까지만요.
그래서 코드에는 이런 방어가 들어 있었습니다.
ch.isLetterOrDigit() || ch.isWhitespace() ->
sb.append(if (ch.code < 256) ch else ' ')
코드값 256 위는 공백으로.
한글 음절은 U+AC00(44032)부터 시작합니다. 전부 256 위예요. 그러니까 한국어 문서의 글자층은 — OCR이 완벽하게 읽어냈어도 — 한 줄이 통째로 공백이 되어 PDF에 들어가고 있었습니다.
이게 ‘조용히’의 정체입니다. 예외도 안 났고, 로그도 없었고, PDF는 잘 열렸어요. 방어 코드가 제 일을 성실하게 한 결과가, 화면의 약속을 거짓말로 만들고 있었습니다. 이건 ‘아직 못 하는 일’이 아니에요 — 화면이 된다고 말하고 있었으니까, 거짓말입니다.
찾으러 가서 찾은 게 아닙니다
이 버그를 발견한 경위가 저는 제일 남습니다.
그날 하던 일은 버그 사냥이 아니라 중복 제거였어요. 글자층을 얹는 자리가
둘이 되면서(스캔 → PDF, 이미지 → PDF) 좌표 계산이 두 벌로 갈릴 참이라,
InvisibleTextLayer 한 곳으로 모으고 있었습니다.
코드를 옮기려면 읽어야 하죠. 그래서 몇 달 동안 아무도 다시 읽지 않던
sanitize 함수를 읽게 됐고, 그 안의 ‘256 위는 공백으로’를 봤습니다.
중복을 걷는 일의 명분은 코드 줄 수를 줄이는 겁니다. 그런데 실제로 얻은 건 그 안에 있던 것을 다시 보게 되는 일이었어요. 두 벌로 흩어져 있는 동안에는 어느 쪽도 다시 읽힐 기회가 없었습니다.
고치기 — 폰트를 받아 오지 않고 구웠습니다
한글을 그리려면 한글 글리프가 있는 폰트를 PDF에 실어야 합니다. 새 폰트를 받아올 필요는 없었어요 — 앱에 이미 Pretendard(OFL)가 있고, 라이선스 고지도 이미 서 있었으니까요.
다만 두 가지 손질이 필요했습니다. tools/bake-ocr-font.py라는 스크립트를
저장소에 두고 거기서 합니다.
하나, 필요한 글자만 남깁니다(서브셋). 여기서 기준이 중요한데 —
담을 글자는 ‘앱이 지원하는 열한 언어’가 아니라 ‘OCR이 뱉을 수 있는 글자’입니다.
인식기가 KoreanTextRecognizerOptions 하나뿐이면 일본어 글자는 애초에
만들어지지 않아요. 만들어지지 않는 글자의 글리프를 싣는 건 낭비입니다.
그래서 한글 음절 11,172자 + 라틴 + 통화 기호(₩ € £) + 문장부호, 모두 11,721자.
둘, OTF를 TrueType으로 옮깁니다. Pretendard는 OTF(CFF)인데 PDFBox의
PDType0Font는 TrueType의 glyf를 읽습니다. cu2qu로 곡선 문법을 바꿔요 —
모양을 바꾸는 게 아니라 같은 모양을 다른 문법으로 다시 적는 일입니다.
이렇게 구운 2.14 MB짜리 폰트를 assets에 싣고, 이제 이렇게 로드합니다.
context.assets.open("ocr_text_layer.ttf").use {
PDType0Font.load(document, it, /* embedSubset = */ true)
}
“2 MB를 실으면 PDF마다 2 MB씩 커지는 거 아냐?” — 아닙니다.
embedSubset = true면 실제로 그린 글자만 PDF에 담겨요. 재어 보니
한글 한 줄을 담은 PDF가 4 KB였습니다. 커지는 건 APK뿐이고, 그건 한 번 치르면
끝입니다.
‘만들어졌다’와 ‘된다’는 다른 말이라서
고치고 나서, 진짜로 검색이 되는지를 확인해야 했습니다. 그런데 ‘폰트가 실렸다’는 확인이 아니에요. 실리고도 검색이 안 될 수 있거든요.
PDF에서 검색을 지는 것은 ToUnicode라는 표입니다 — “이 글리프는 유니코드로 이 글자다”라는 대응표요. 그래서 시험도 그걸 겨눴습니다. pdfbox-android를 JVM에서 직접 돌려서, 만들어진 PDF의 ToUnicode를 되읽어
계약서 임대차 보증금 ₩1,850,000 café
가 그대로 나오는 것까지 봤습니다. 이게 나오면 어떤 PDF 뷰어든 찾을 수 있어요. ‘폰트가 실린다’와 ‘PDF가 4 KB다’는 ‘만들어졌다’만 말합니다. 검색이 되는지는 ToUnicode가 집니다.
결국 바꾼 것
- 성실한 방어 코드가 조용한 거짓말이 될 수 있습니다. ‘256 위는 공백으로’는 표준 폰트의 한계에 대한 올바른 방어였어요. 다만 그 방어가 화면의 약속과 부딪히는지는 아무도 세지 않았습니다.
- 중복 제거의 진짜 소득은 줄어든 줄 수가 아니라, 다시 읽게 되는 일입니다.
- 폰트가 덮어야 하는 건 앱의 언어가 아니라 인식기가 뱉는 글자입니다. 경계는 화면이 아니라 데이터가 태어나는 자리에 있어요.
- ‘만들어졌다’는 ‘된다’가 아닙니다. 시험은 결과물을 되읽는 데까지 가야 합니다.