기술 문서

Delphi에서 기존 PDF 내의 텍스트 검색 및 치환하기

HotPDF Component는 Delphi 및 C++Builder에서 기존 PDF 파일 내부의 텍스트를 검색하고 치환하는 기능을 지원합니다. SearchLoadedPageTextSearchLoadedDocumentText는 문자 모양(glyph) 수준의 정밀도로 검색어의 출현 위치를 정확히 찾아내며, ReplaceLoadedPageTextReplaceLoadedDocumentText는 매칭된 스트림 바이트를 인플레이스(in-place) 방식으로 재작성합니다. 단, 치환하려는 문자열의 모든 문자가 원본 글꼴을 통해 올바르게 재인코딩(re-encoded)될 수 있어야 한다는 물리적인 제약 조건이 따르며, 본 기사는 이를 각주에 숨기지 않고 상세히 다룹니다

이 기능이 필요한 비즈니스 요구는 대개 아주 평범합니다. 회사 이름이 변경되어 보관된 3천 개의 인보이스 파일을 모두 새 사명으로 변경해야 하거나, 계약서 서식 파일의 만료일 연도가 작년으로 오기입되었거나, 기존 단종 상품 코드를 후속 제품 코드로 일제히 변경해야 하는 상황 등입니다. 워드프로세서에서는 30초면 해결될 일이지만, PDF 문서에서는 구조적으로 매우 난도가 높은 작업입니다. 그 이유를 이해해야 API를 유용하게 잘 활용할 수 있으며, 규격 한계에 따른 현상을 단순히 컴파일 버그로 오판하지 않게 됩니다

왜 PDF 내의 텍스트 치환이 이토록 어려울까요?

PDF 내의 텍스트 치환이 까다로운 이유는 PDF 페이지가 편집 가능한 텍스트를 저장하는 대신 좌표 정보가 있는 문자 모양(glyph)을 그리기 때문입니다. ISO 32000-1 §9.4의 텍스트 표시 모델에 따르면, 콘텐츠 스트림은 텍스트 변환 매트릭스에 의해 지정된 좌표 위치에 연속된 문자 코드를 그리는 TjTJ 연산자를 구동합니다. 이 문자 코드들은 유니코드가 아니라 페이지의 글꼴이 선언한 특정 인코딩의 인덱스 수치일 뿐이며, 이를 다시 사람이 읽을 수 있는 문자로 변환하는 역매핑 정보는 /ToUnicode CMap 테이블이나 인코딩 Differences 배열, 혹은 CID 매핑 체인 내에 분산되어 보관됩니다. PDF에는 문단(paragraph) 객체도 없고, 흐르는 텍스트 개념도 없으며, 화면에 보이는 하나의 단어가 스트림 내에 단일 문자열로 온전히 저장되어 있으리라는 보장도 없습니다

치환 기능은 텍스트 디코딩에 더해 두 번째 난관을 수반합니다. 바로 원본 스트림의 정확히 어떤 바이트 데이터가 개별 글자 모양을 그려냈는지 추적해야, 그 영역만을 정교하게 도려내어 새 바이트 데이터로 교체할 수 있기 때문입니다. 단순히 텍스트를 추출하는 파서라면 유니코드를 분석한 후 원본 바이트 오프셋 정보를 버려도 무방하지만, 치환기는 이 정보를 끝까지 유지해야 합니다. HotPDF가 이 기능을 구현하기 위해 v2.251.0 버전에서 오프셋 추적 및 검색 레이어를 먼저 구축하고, v2.252.0 버전에서 그 기반 위에 재작성(rewriting) 레이어를 덧올리는 단계적 개발을 진행한 이유가 여기에 있습니다

텍스트 검색: 바이트 오프셋 추적 기반의 문자 수준 검색

HotPDF의 SearchLoadedDocumentText는 원시 스트림 바이트가 아니라 각 페이지의 디코딩된 유니코드 문자 모양 시퀀스를 대조하여 검색어를 찾아내므로, 글꼴이 어떻게 인코딩되어 있든 검색 대상을 확실히 식별해 냅니다. v2.251.0 버전에 탑재된 기초 아키텍처 덕분에, 콘텐츠 스트림 토크나이저(tokenizer)는 괄호 ( ) 또는 꺾쇠괄호 < > 구분 기호를 포함한 모든 문자열 피연산자의 StartOfs/EndOfs 바이트 범위를 꼼꼼하게 기록합니다. 또한 디코딩된 모든 글자 모양은 자신을 생성해 낸 피연산자 연산 영역, TJ 배열 항목 및 문자 코드 단위를 가리키는 TokenIndex/ItemIndex/ByteOffset의 3각 참조 정보를 가집니다. 이 해석 엔진은 앞서 Delphi에서 로드된 PDF로부터 텍스트 추출하기에서 다룬 추출 API와 동일한 엔진으로, 검색 모드에서는 추출 모드가 파기해 버리는 오프셋 매핑 이력을 그대로 유지하여 응용합니다

검색 매칭 결과는 페이지 인덱스, 일치하는 글자 범위, 사용자 좌표계 기준 시작 X/Y 좌표 및 단어 전체 폭, 소스 토큰 및 배열 인덱스 정보, 그리고 일치하는 원본 텍스트를 담은 THPDFTextMatch 레코드 구조체로 전달됩니다. 이 정보들을 활용하면 화면상의 강조 표시(highlight) 오버레이 레이어, 검토용 UI, 혹은 최종 치환 작업을 손쉽게 구현할 수 있습니다. 일치하는 단어가 없는 경우 에러를 일으키는 대신 빈 배열을 리턴하여 단순한 코딩 구조를 유지할 수 있게 돕습니다

var
  Pdf: THotPDF;
  Matches: THPDFTextMatchArray;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoices-2025.pdf') > 0 then
    begin
      if Pdf.SearchLoadedDocumentText('Acme Corp', False, Matches) then
        for I := 0 to Length(Matches) - 1 do
          WriteLn(Format('page %d at (%.1f, %.1f): "%s"',
            [Matches[I].PageIndex, Matches[I].X, Matches[I].Y,
             Matches[I].Text]));
    end;
  finally
    Pdf.Free;
  end;
end;

의도된 한 가지 디자인 고려 사항을 짚고 넘어갈 필요가 있습니다. CaseSensitive 옵션이 False일 때, 영문 대소문자 변환(case folding)은 오직 ASCII 표준 영문자에만 적용되도록 설계되었습니다. 다국어 전체 유니코드 대소문자 변환 처리는 HotPDF가 폭넓게 지원하는 Delphi 5부터 XE 제품군에 이르는 서로 다른 컴파일러 환경마다 지원 특성이 판이하므로, 컴파일러 버전에 따라 검색 매칭 결과가 다르게 수집되는 불안정한 API보다는 예측 가능하고 문서화된 일관된 제한 기준을 제시하는 것이 낫기 때문입니다. 이름, 코드, 날짜 등의 일반적인 업무용 텍스트의 경우 ASCII 표준 영문 대소문자 구분 해제만으로 대부분의 실무 요구 사항을 원활히 처리할 수 있습니다

텍스트 치환: 역방향 인코딩 및 정교한 스트림 결합

HotPDF v2.252.0 버전에 추가된 ReplaceLoadedDocumentText는 텍스트 디코딩 파이프라인 단계를 역방향으로 실행하여 검색어를 새로운 텍스트로 치환합니다. 내부의 HPDFEncodeUnicode 함수는 역변환 담당자로서 /ToUnicode bfchar 및 bfrange 대조, 인코딩 스트림 CID 매핑, Type0의 ID 매핑 목록 및 기본 탑재된 WinAnsi/MacRoman 테이블 등의 변환 체인을 거슬러 올라가며, 교체할 새 문자열을 원래 글꼴이 매핑하고 해독할 수 있는 원시 문자 코드 바이트로 역변환합니다. 변환된 바이트 데이터는 파서와 토크나이저의 예외 처리(escaping) 규칙에 일치하도록 정형화된 리터럴 괄호 문자열이나 16진수 형식으로 직렬화되어 안정적으로 기록됩니다

이러한 조립 과정은 스트림 전체를 재배치하지 않고 필요한 부분만 도려내어 교체합니다. 문자열 피연산자 내에서 일치하는 바이트 범위 영역만을 교체하며, 동일 피연산자 내의 비매칭 문자나 토큰 사이의 공백 문자, 인근의 타 드로잉 연산자 바이트들은 단 1바이트의 오차도 없이 그대로 보존됩니다. 예컨대 abcabc 문자열에서 bca 영역을 교체할 때, 피연산자 전체를 새로 고치지 않고 a + 새 문자열 + bc의 형태로 매끄럽게 교체합니다. 새 문자열이 기존 문자열보다 짧거나 길어도 무방하며(직렬화를 거쳐 /Length 수치가 자동 수정됨), 다중 콘텐츠 스트림 페이지의 경우 각 /Contents 섹션별로 분할되어 정밀 처리되므로 PDF 문서 구조의 완성도가 유지됩니다

var
  Pdf: THotPDF;
  ReplaceCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-draft.pdf') > 0 then
    begin
      if Pdf.ReplaceLoadedDocumentText('2025-12-31', '2026-12-31',
        True, ReplaceCount) then
        WriteLn(Format('%d operand rewrites performed', [ReplaceCount]));
      Pdf.SaveLoadedDocument('contract-final.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

이 API가 지원하지 않는 한계를 확인하십시오. 바로 페이지 텍스트 배치를 새로 정렬(re-typeset)해 주지 않는다는 점입니다. PDF는 줄 바꿈 후속 흐름 제어(reflow) 기능이 없으므로, 치환할 단어의 실제 가로 출력이 원본보다 넓으면 단순히 오른쪽 좌표 방향으로 겹쳐서 표시되어 인접한 다른 글자와 포개질 수 있습니다. 날짜, 버전 식별자, 제품 번호, 오타 수정처럼 기존 문구와 길이가 비슷하거나 짧은 경우에 적용하는 것이 가장 이상적입니다. 문단 전체의 내용을 크게 개편해야 하는 작업은 PDF 단계가 아닌, 원본 편집용 소스 문서에서 수정한 후 PDF를 재발행해야 합니다

왜 텍스트 치환이 폰트 서브셋에 누락된 문자로 불가능할까요?

내장 글꼴 서브셋에 누락된 문자를 치환 텍스트로 사용할 수 없는 이유는, 해당 문자를 지칭하고 지정할 수 있는 바이트 인덱스 매핑 규칙 자체가 폰트 테이블 내에 애초에 정의되어 있지 않기 때문입니다. PDF 생성기가 서브셋 형태로 글꼴 정보를 수록할 때, /ToUnicode CMap 및 인코딩 테이블 데이터에는 오직 원본 페이지에서 쓰인 글자 모양만을 포함시킵니다. HPDFEncodeUnicode는 폰트에 수록된 매핑만을 역방향으로 해석하므로, 원본 문서에 해당 글꼴로 기록된 문자 E가 한 번도 출현한 적이 없다면 문자 E를 나타내기 위한 매핑 규칙 자체가 부재하게 됩니다. 이는 PDF 파일 포맷 자체가 가지는 물리적 제약 사양일 뿐 특정 라이브러리의 한계가 아닙니다. 세상의 그 어떤 도구도 파일 내에 수록되지 않은 글자 매핑 정보를 무에서 창조해 낼 수는 없습니다

HotPDF는 이 변환 실패 예외를 안전하고 보수적으로 처리합니다. 치환용 새 문자열 중 단 한 글자라도 글꼴 역변환에 실패하면, 치환 작업을 취소하고 해당 검색어 위치를 그냥 건너뜁니다. 오류 예외를 터뜨리거나 깨진 글자를 대충 삽입하지 않으며, 이 건너뛰기 횟수는 최종 ReplaceCount 카운트에서도 제외됩니다. 실무 조언: 텍스트 검색을 통해 파악된 총 검색 횟수와 실제 ReplaceCount 완료 횟수를 대조하여 치환 누락 여부를 판단하십시오. 앞선 날짜 예제의 경우, 2025를 2026으로 치환하려면 원본 문서의 동일 글꼴에 최소한 숫자 6이 한 번이라도 출현한 이력이 있어야 매핑 테이블을 거슬러 올라가 치환에 성공할 수 있습니다. 폰트에 매핑 정보가 없어 치환이 불가능한 상태에서, 수정 목적이 정보 교체가 아닌 민감한 데이터의 영구 삭제인 경우라면 텍스트 가림(redaction) 도구를 응용해 보십시오. Delphi에서 로드된 PDF의 콘텐츠 가림(redaction) 및 구조 변경 기사에서 해결책을 확인하실 수 있습니다

var
  Matches: THPDFTextMatchArray;
  Expected, Replaced: Integer;
begin
  Pdf.SearchLoadedDocumentText('Acme Corp', True, Matches);
  Expected := Length(Matches);
  Pdf.ReplaceLoadedDocumentText('Acme Corp', 'Apex Corp', True, Replaced);
  if Replaced < Expected then
    WriteLn(Format('%d occurrence(s) skipped: characters missing ' +
      'from the font subset, or match spans multiple operands',
      [Expected - Replaced]));
end;

위 리포트에서 언급된 두 번째 건너뛰기 예외 조건은 검색어가 다중 문자열 피연산자에 나뉘어 걸쳐 있는 상황입니다. 예컨대 단어 Hello[(He)(llo)] TJ와 같이 분할되어 수록된 경우, 텍스트 검색 모드에서는 디코딩된 유니코드 시퀀스를 기반으로 하므로 단어를 정상 감지하지만, 치환 모드는 이 분할된 경계를 가로질러 바이트를 새로 결합하고 재편성하는 복잡한 스트림 변조를 피하기 위해 처리를 건너뜁니다. 검색 후 검증을 거치는 코딩 패턴을 적용하면 이러한 두 가지 스펙 제한 조건을 화면에 명확히 표출해 줄 수 있습니다

문서를 저장하면 어떤 내용들이 변하나요?

치환 처리가 완료된 /Contents 스트림은 압축되지 않은 상태로 저장됩니다. 기존 FlateDecode 방식으로 압축되어 있던 스트림을 편집을 위해 디코딩한 후, HotPDF는 재구성된 바이트를 쓰면서 재압축 처리를 건너뛰고 /Filter 플래그를 제거한 채 /Length 수치 정보만을 갱신하여 기록합니다. 저장된 PDF는 모든 뷰어에서 정상적으로 렌더링되지만, 편집된 스트림의 용량이 다소 늘어나는 트레이드오프가 있습니다. 대량의 파일을 고속 처리하는 파이프라인 시스템을 구축하는 경우 이러한 용량 증가를 무시하기 어려우므로 사후 단계에서 별도의 압축 패스를 별도로 연결해 가동해 주어야 합니다. 직렬화 저장 시 수정된 객체들이 교차 참조 테이블 구조와 어떻게 연결되는지는 HotPDF의 객체 스트림 및 증분 업데이트 가이드에서 상세히 확인하십시오

수정되지 않은 다른 파일 영역들은 전혀 영향받지 않고 보존됩니다. 편집되지 않은 타 스트림들의 압축 상태는 완벽히 보존되고, 글꼴 및 이미지 자원도 원래 위치를 지키며, 피연산자 단위 교체 방식 덕분에 편집 완료된 스트림조차 치환된 지점을 제외하면 원본과 한 치의 차이도 없이 유지됩니다. 이 보수적인 접근 방식은 의도된 설계입니다. 문서의 뼈대를 지나치게 임의 변조하여 쓰면, 원본 생성기의 사소한 특징이나 호환성을 깨뜨릴 위험성이 증가하기 때문입니다

텍스트 검색 및 치환 기능은 텍스트 추출, 콘텐츠 가림, 페이지 렌더링 기능들과 유기적으로 통합되어 HotPDF의 로드된 문서 가공 제품군을 구성합니다. 모든 처리는 동일한 콘텐츠 스트림 해석기를 공유해 작동하며, 별도의 외부 라이브러리 종속성 없이 Delphi 5부터 XE 제품군에 이르는 모든 개발 환경을 완벽히 지원합니다. 전체 API 레퍼런스 및 평가판 다운로드는 HotPDF Component의 공식 소개 페이지에서 확인하십시오