HotPDF는 HPDFCreateTesseractOCREngine을 통해 스캔 PDF 페이지를 검색 가능한 PDF로 바꿉니다. 로컬에 설치된 Tesseract 실행 파일을 IHPDFOCREngine으로 감싸는 팩토리입니다. 그 엔진을 ApplyLoadedOCRTextLayer에 넘기면, 각 페이지를 렌더링하고 페이지마다 Tesseract를 한 번씩 돌려 워드 수준 TSV 출력을 파싱하고, 요청된 모든 페이지에 대해 보이지 않는 유니코드 텍스트 레이어를 하나의 트랜잭션으로 커밋하거나 아무것도 하지 않습니다
이 어댑터가 존재하는 이유는 범위입니다. 내장 템플릿 매칭 OCR 엔진은 의도적으로 좁습니다. 기계 인쇄된 ASCII 글자와 숫자, 그게 전부입니다. 악센트가 붙은 이름의 청구서, 중국어 계약서, 다국어 아카이브는 학습된 언어 모델을 갖춘 진짜 인식기가 필요하고, Tesseract가 뻔한 후보입니다. 애플리케이션 옆에 구비할 수 있는 커맨드라인 프로그램이니까요. 문서 라이브러리에서 외부 프로그램을 호출하는 건 사소해 보입니다. 사소하지 않고, 어댑터의 흥미로운 코드 대부분은 프로그램이 오작동하거나, 걸리거나, 취소되거나, 절대 봐서는 안 될 것들을 물려받을 때 무슨 일이 벌어지는지에 관한 것입니다
HotPDF는 Delphi 애플리케이션에서 Tesseract를 어떻게 구동할까?
HotPDF는 페이지마다 Tesseract를 숨은 자식 프로세스로 돌리고 렌더링된 비트맵을 먹이고 TSV 파일을 되돌려 읽으며, 결과를 내장 엔진이 쓰는 것과 같은 IHPDFOCREngine 이음매로 노출합니다. 하류는 아무것도 바뀌지 않습니다. 좌표 매핑, 회전 처리, 유니코드 검증, 신뢰도 필터링, 원자적 커밋은 이미 갖고 있는 텍스트 레이어 파이프라인입니다. 팩토리는 HPDFTesseractRecognition 유닛에 살며 즉시 검증합니다. 실행 파일이 존재해야 하고, tessdata 디렉터리가 존재해야 하며, 시간 제한은 1부터 3,600,000밀리초 사이여야 하고, 언어 식별자는 ASCII 글자, 숫자, _, +만 담을 수 있습니다. 마지막 검사가 중요한 이유는 언어 문자열이 커맨드라인에 실리는데, eng+chi_sim은 정당한 Tesseract 값이지만 따옴표나 공백이 들어간 것은 아니기 때문입니다
uses
SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;
procedure MakeSearchable(const SourceFile, TargetFile: string;
Token: THPDFCancellationToken);
var
Doc: THotPDF;
Engine: IHPDFOCREngine;
Options: THPDFOCRTextLayerOptions;
Info: THPDFOCRTextLayerInfo;
begin
// 실행 파일 누락, tessdata 누락, 잘못된 언어 식별자, 1..3600000 ms 밖의
// 시간 제한에 대해 EArgumentException을 일으킵니다
Engine := HPDFCreateTesseractOCREngine(
'C:\OCR\Tesseract\tesseract.exe',
'C:\OCR\Tesseract\tessdata',
'eng+chi_sim', // '+'로 이은 여러 모델
120000); // 페이지당 한도, 기본은 60000
Doc := THotPDF.Create(nil);
try
Doc.AutoLaunch := False;
if Doc.LoadFromFile(SourceFile) < 1 then
raise Exception.Create('Cannot load ' + SourceFile);
Options := THPDFOCRTextLayerOptions.Default; // 300 DPI, MinimumConfidence 0.5
Options.CancellationToken := Token;
// 빈 페이지 목록은 모든 페이지를 뜻합니다. 텍스트가 있는 페이지는 기본으로 건너뜁니다
if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
begin
Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
' words accepted, ', Info.DroppedWordCount, ' dropped');
Doc.SaveLoadedDocument(TargetFile);
end
else
case Info.Status of
otlsCancelled: Writeln('Cancelled, document unchanged');
otlsEngineError: Writeln('Engine: ', string(Info.Diagnostic));
otlsBudgetExceeded: Writeln('Budget: ', string(Info.Diagnostic));
else
Writeln(string(Info.Diagnostic));
end;
finally
Doc.Free;
end;
end;
각 페이지마다 Recognize는 temp 경로 아래 HotPDF-OCR-{GUID}라는 private 디렉터리를 만들고, 렌더링된 비트맵을 input.bmp로 저장하며, tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1을 띄웁니다. 모든 경로 인자는 백슬래시와 내장 따옴표에 대한 Windows 커맨드라인 이스케이프 규칙으로 따옴표 쳐서 넘깁니다. --dpi 값은 THPDFOCRTextLayerOptions.DPI의 렌더 DPI라서 Tesseract가 이미지 메타데이터로 해상도를 추측할 일이 없고, --psm 3은 완전 자동 페이지 분할을 요청합니다. 엔진은 자신을 Tesseract (local CLI)로 보고하며, 이것이 Info.EngineName에 담깁니다. Tesseract와 그 언어 모델은 HotPDF에 번들되지 않습니다. 설치는 애플리케이션의 몫입니다
TSV 파서는 왜 그렇게 엄격할까?
HotPDF의 TSV 파서는 malformed 행 하나에도 페이지 전체를 실패 처리합니다. 부분적으로 파싱된 워드 목록은 이미지와 조용히 어긋나는 텍스트 레이어를 만들기 때문입니다. Tesseract의 TSV 출력은 level부터 text까지 고정된 12열 헤더를 가지고, HotPDF는 선택적 BOM을 떼어낸 뒤 첫 줄을 그 정확한 헤더와 비교합니다. 이어지는 모든 행은 정확히 12개 필드로 쪼개져야 하고, 분할은 11번째 탭 뒤에서 멈춰서 인식된 텍스트 안의 탭이 13번째 열을 만드는 대신 워드의 일부로 남게 합니다. 레벨 5 행만 워드입니다. 레벨 1부터 4는 페이지, 블록, 문단, 줄을 기술하며 건너뜁니다. 텍스트가 비었거나 순수 공백인 레벨 5 행도 건너뜁니다. 빈 워드는 박스는 있지만 위치시키거나 검색할 것이 없기 때문입니다. 나머지는 전부 딱딱하게 검사합니다. 정수 기하, 독일 로케일이 93.5를 쓰레기로 읽지 않도록 불변 en-US 형식으로 파싱한 신뢰도, 비트맵 안에 완전히 들어간 박스, 0부터 100 사이의 신뢰도입니다. 실패가 하나라도 나면 예외를 일으키고, 엔진은 False를 반환하며, 워드 배열은 지워집니다. 회귀 테스트에는 정확히 그 케이스가 들어 있습니다. 유효한 워드 하나 뒤에 깨진 행이 오면 워드 하나가 아니라 0개를 내놓아야 합니다
// HPDFLocalTSVRecognition의 레벨 5 루프에서 축약
if (Fields.Count <> 12) or not TryStrToInt(Fields[0], Level) then
raise EConvertError.Create('Invalid Local OCR TSV row');
if Level <> 5 then Continue; // 페이지/블록/문단/줄 행
WordText := Fields[11];
if Trim(WordText) = '' then Continue; // 공백 워드에는 위치가 없음
if not TryStrToInt(Fields[6], X) or not TryStrToInt(Fields[7], Y) or
not TryStrToInt(Fields[8], W) or not TryStrToInt(Fields[9], H) or
not TryStrToFloat(Fields[10], Confidence, Settings) then
raise EConvertError.Create('Invalid Local OCR word geometry');
if (X < 0) or (Y < 0) or (W <= 0) or (H <= 0) or
(Int64(X) + W > Request.Bitmap.Width) or
(Int64(Y) + H > Request.Bitmap.Height) or
not ((Confidence >= 0) and (Confidence <= 100)) then
raise EConvertError.Create('Local OCR word is outside the image');
Words[Count].Confidence := Confidence / 100; // 파이프라인은 0..1을 기대
마지막 줄은 예상 밖의 기본값과 상호작용합니다. Tesseract 신뢰도는 0부터 100인데 파이프라인은 0부터 1로 일하고, THPDFOCRTextLayerOptions.MinimumConfidence의 기본값은 0.5이므로, 50 미만의 Tesseract 워드는 Info.DroppedWordCount에 세어지고 페이지에 닿지 않습니다. 깨끗한 300 DPI 스캔에서는 합리적인 바닥입니다. 노이즈 심한 팩스에서는 페이지의 놀랄 만한 비중을 떨어뜨릴 수 있고, 임계값을 낮추기 전에 떨어뜨린 개수를 보는 게 맞습니다. 낮은 신뢰도의 워드야말로 틀릴 가능성이 가장 높은 워드이니까요
Tesseract 자식 프로세스는 무엇을 물려받을까?
Tesseract 자식 프로세스는 HotPDF로부터 정확히 핸들 둘을 물려받습니다. 표준 입력과 출력용 NUL 핸들, 표준 오류용 파일 핸들입니다. 이 정밀함이 요점입니다. bInheritHandles = True인 CreateProcess가 표준 핸들을 자식에게 넘기는 방법이지만, 그것만으로는 호스트 프로세스의 모든 상속 가능 핸들을 넘깁니다. 애플리케이션의 무관한 코드가 연 파일, 파이프, 이벤트를 포함해서요. 자식은 그 오브젝트들을 자기가 끝날 때까지 살려 두므로, Tesseract가 페이지를 갈아 넣는 동안 파일은 잠긴 채이거나 파이프는 끝을 영영 못 봅니다. HotPDF는 확장 시작 레코드로 그 간극을 막습니다. STARTUPINFOEX, PROC_THREAD_ATTRIBUTE_HANDLE_LIST를 실은 속성 목록, EXTENDED_STARTUPINFO_PRESENT 생성 플래그입니다. 핸들 목록이 있으면 bInheritHandles는 여전히 True여야 하지만 경계를 넘는 것은 목록에 오른 핸들뿐입니다. 같은 봉쇄 사고가 워커 프로세스로 PDF 이미지 코덱 격리하기를 움직입니다. 거기서는 자식이 신뢰할 수 없는 코드이고, 여기서는 자식이 신뢰되지만 호스트가 자기 핸들 테이블의 유일한 소유자는 아닙니다
// 상수는 이름으로 표시, 소스는 숫자 값을 넘김
// 두 핸들 모두 bInheritHandle = True로 생성
InheritedHandles[0] := NullHandle; // stdin과 stdout
InheritedHandles[1] := ErrorHandle; // private 디렉터리의 stderr.txt
InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, AttributeBytes);
UpdateProcThreadAttribute(Startup.AttributeList, 0,
PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
@InheritedHandles[0], SizeOf(InheritedHandles), nil, nil);
CreateProcess(PChar(Executable), PChar(Command), nil, nil,
True, // 핸들 목록이 요구
CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);
취소된 OCR 실행이 엔진 실패처럼 보일 수 있는 이유는?
취소된 OCR 실행이 엔진 실패처럼 보이는 이유는 IHPDFOCREngine.Recognize가 불리언 하나를 반환하는데, False가 "Tesseract 실패"와 "사용자가 Cancel을 눌렀음" 양쪽을 뜻하기 때문입니다. 어댑터는 자식이 도는 동안 25밀리초마다 취소 토큰과 시간 제한을 폴링하고, 토큰이 발화하면 Recognize 안에서 예외를 일으켜 자기 예외를 잡고, 뒷정리하고, 진단을 담아 False를 반환합니다. 파이프라인이 그것을 엔진 오류로 치면 호출자는 사용자가 의도적으로 멈춘 작업에 otlsEngineError를 보게 됩니다. 그래서 ApplyLoadedOCRTextLayer는 Recognize가 False를 반환할 때마다 토큰을 먼저 검사하고, 토큰이 설정되지 않았을 때만 결과를 엔진 실패로 바꿉니다. 이 순서가 다중 페이지 계약을 지킵니다. 인식, 검증, 예산 회계, 콘텐츠 구성은 그래프 트랜잭션이 열리기 전에 요청된 모든 페이지에 대해 돌므로, 50페이지 중 40페이지에서의 취소는 otlsCancelled를 보고하고 처음 39페이지를 포함해 문서를 그대로 둡니다. 나중에 설명할 부분 검색 파일은 존재하지 않고, 나머지 실패 처리도 같은 유계 스타일을 따릅니다:
- 시간 제한은
Recognize호출마다 시작부터 재며, 그래서 기본 60,000 ms는 문서 전체가 아니라 각 페이지에 적용됩니다 - 시간 초과나 취소 시점에 아직 도는 자식은 종료되고 최대 5초까지 기다린 뒤, private 디렉터리는
finally블록에서 삭제됩니다 output.tsv는 64 MiB,stderr.txt는 1 MiB로 제한되며, 자식이 도는 동안에도 끝난 뒤에도 검사됩니다- 워드 수와 UTF-16 코드 유닛은 남은
MaxWordsPerPage,MaxTotalWords,MaxTextCodeUnits예산으로 페이지마다 제한되고, 넘치면 워드 목록을 잘라 내는 대신 실행이 실패합니다 - 표준 출력은
NUL로 갑니다. Tesseract는output.tsv에 쓰기 때문입니다. 표준 오류는 파일로 가서 0이 아닌 종료 코드가 엔진 자신의 불평 4,096자까지 실려 보고되는데, 보통.traineddata파일이 없음을 알아차리는 가장 빠른 길입니다
인식된 워드가 보이지 않는 텍스트 레이어가 되는 과정
HotPDF는 Tesseract 워드를 텍스트 렌더링 모드 3, 즉 ISO 32000-1 §9.3.6이 정의한 채움도 스트로크도 안 하는 모드로 보이지 않는 텍스트로 기록합니다. 그래서 페이지는 스캔 이미지를 그대로 보여 주면서 검색과 복사는 인식된 워드 위에서 동작합니다. 콘텐츠 스트림은 3 Tr로 BT를 열고, 각 워드는 베이스라인에 Tm 행렬, 렌더 DPI에서의 박스 높이 픽셀로부터 파생한 폰트 크기, 그리고 글리프 런을 측정된 박스 너비까지 늘이는 Tz 수평 스케일을 받습니다. 검색 하이라이트가 이미지 위를 흘러다니는 대신 이미지 속 워드에 착지하는 이유입니다
Tesseract의 TSV에는 박스는 있지만 베이스라인이 없으므로, 어댑터는 베이스라인 없이 모든 워드를 보고하고 파이프라인은 베이스라인을 아래 모서리에서 박스 높이의 5분의 1 위로 추정합니다. 텍스트 자체는 Identity-H 인코딩과 생성된 ToUnicode CMap을 갖춘 공유 비임베드 Type0 폰트를 거치며, 전체 실행에서 서로 다른 유니코드 스칼라마다 CID 하나입니다. 중국어와 악센트 라틴 문자, 보충 평면 문자가 모두 복사와 검색을 버티는 방법이 이것입니다. 이 설계에는 미리 말해 둘 한계가 둘 있습니다. 한 실행은 서로 다른 스칼라를 최대 65,535개까지 실을 수 있고, 비임베드 폰트는 ISO 19005의 폰트 임베드 요구를 충족하지 않으므로 PDF/A 출력에는 별도로 임베드된 컨포밍 폰트가 필요합니다. 결과 확인은 간단하고 자동화할 가치가 있습니다. 저장하고 다시 불러서 Delphi에서 불러온 PDF의 텍스트 추출의 평범한 불러온 문서 텍스트 경로를 돌리세요. 워드들이 기대한 페이지로 돌아오면 레이어는 진짜입니다
같은 TSV 프로토콜 위의 RapidOCR와 다른 엔진들
HotPDF는 RapidOCR에 대해서도 같은 프로세스 러너와 TSV 파서를 HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds)으로 재사용합니다. 간체 중국어 스캔에는 더 유용한 선택입니다. 커맨드라인은 브리지 스크립트 경로가 Python 실행 파일 뒤에 삽입된다는 점만 빼면 동일하고, 언어는 chi_sim으로 고정됩니다. HotPDF는 브리지를 tools/OCR/rapidocr_tsv.py로 배송합니다. rapidocr와 onnxruntime 패키지, 그리고 로컬 ONNX 모델 셋을 요구하고, 자동 모델 다운로드를 끄며, Tesseract 모양 TSV를 써서 Delphi 쪽이 두 번째 파서를 필요로 하지 않게 합니다. Info.EngineName에 보고되는 엔진 이름은 RapidOCR (local ONNX)입니다. 그 모양이 일반 레시피를 시사합니다. Tesseract식 인자 목록을 받아 12열 TSV를 내놓는 작은 스크립트로 감쌀 수 있는 어떤 인식기든 핸들 격리와 시간 제한, 취소, 출력 예산, 전부 아니면 전무 커밋을 공짜로 물려받습니다. 어댑터는 Windows 전용이고 한 번에 한 페이지씩 동기로 돌며, 렌더러가 내놓는 것 너머의 데스큐나 전처리는 하지 않으므로, 들어가는 이미지 품질이 여전히 나오는 것의 천장을 정합니다
Tesseract와 RapidOCR 어댑터, 보이지 않는 텍스트 레이어 작성기, 그것들을 먹이는 페이지 렌더러, 결과를 검증하는 텍스트 추출은 모두 Delphi와 C++Builder용 같은 네이티브 VCL 컴포넌트에 들어 있습니다. 문서 캡처나 아카이브 애플리케이션에 OCR을 얹는 중이라면 HotPDF Delphi PDF component가 파이프라인을 주고 남은 것은 OCR 엔진 자체를 설치하는 일뿐입니다