기술 문서

편집 후 낡은 텍스트: PDFium의 FPDF_TEXTPAGE 캐시

PDFiumPas로 AddText를 호출해 PDF 페이지에 한 줄을 찍은 뒤, 곧바로 FindFirst를 호출해 그 도장이 제대로 찍혔는지 확인하면, 검색은 빈손으로 돌아온다. 텍스트는 페이지 위에 있다 — Acrobat도 그것을 보여준다 — 하지만 PDFiumPas의 TPdf 컴포넌트는 페이지의 콘텐츠 스트림에서 한 번 파싱된 별도의 캐시된 FPDF_TEXTPAGE 구조체를 유지하며, 편집이 그 구조체를 소급해서 자동으로 갱신하지는 않는다. 갱신되기 전에 이를 조회하면 여러분의 변경 이후가 아니라 정확히 변경 이전의 모습 그대로 페이지를 읽게 된다

PDFium은 왜 편집 직후 낡은 텍스트를 반환하는가?

PDFiumPas는 Delphi와 C++Builder를 위해 Google의 PDFium 렌더링 엔진을 감싸며, 그 텍스트 및 편집 호출은 그 엔진 안의 두 가지 다른 서브시스템에 닿는다. FPDF_TEXTPAGE는 읽기 쪽에 속한다: FPDFText_LoadPage는 페이지의 콘텐츠 스트림을 한 번 순회해 텍스트 페이지 — 문자 코드, 위치, 폰트 메트릭, 단어 경계 — 를 만들고, PDFiumPas는 그 구조체를 페이지가 로드되어 있는 동안 계속 캐시해 둔다. FPDFPage_InsertObjectFPDFPage_GenerateContent 같은 편집 호출은 완전히 다른 표현, 즉 페이지의 객체와 콘텐츠 스트림 그래프를 다루며, PDFium은 그 변경사항을 이미 열려 있는 텍스트 페이지로 스스로 밀어 넣지 않는다. 매 편집마다 이를 재구축하면 배치 편집이 감당할 수 없을 만큼 느려질 것이므로, 이 설계는 그 비용을 규칙으로 맞바꾼다 — 핸들을 쥐고 있는 쪽이 콘텐츠를 바꾸는 편집 후에 그것을 닫고, 다음 읽기가 새것을 만든다

TPdf의 텍스트 캐시 내부: FTextPage, LoadTextPage, UnloadTextPage

TPdf는 캐시된 핸들을 단일 비공개 필드인 FTextPage로 추적하며, 그 생명주기를 두 메서드로 감싼다. LoadTextPageFTextPage가 nil인지 확인하고, 그 경우에만 현재 페이지에 대해 FPDFText_LoadPage를 호출한다; 이미 핸들이 존재한다면 LoadTextPage는 페이지가 그것이 만들어진 이후 바뀌었는지 묻지 않고 그대로 재사용한다. UnloadTextPage는 그 반대 절반이다: FPDFText_ClosePage로 네이티브 핸들을 닫고, FTextPage를 다시 nil로 되돌리며, 캐시된 웹 링크 목록과 진행 중이던 검색 세션도 함께 버린다. 둘 다 같은 텍스트 페이지에서 파생되었고 같은 이유로 낡아버리기 때문이다

LoadTextPage의 확인 없는 재사용 동작이야말로 순서가 중요한 이유다. TPdf의 모든 텍스트 조회 — Text, FindFirst, GetWebLinks — 는 먼저 LoadTextPage를 거치므로, FTextPage가 여전히 편집 전 핸들을 붙잡고 있는 한 이 호출들 어느 것도 변경이 일어났음을 알 방법이 없다. 페이지 내비게이션은 여기서 결코 위험 요소가 아니었다: 페이지 전환, 다시 로드, 문서 닫기에서 실행되는 UnloadPage는 항상 페이지 자체와 함께 텍스트 페이지도 닫아왔다. 항상 열려 있던 질문은 여러분이 여전히 머물러 있는 페이지에 적용된 편집에 관한 것이었다

어느 PDFiumPas 메서드가 캐시를 자동으로 갱신하는가?

TPdf 자체의 페이지 편집 메서드 — AddText, SetText, SetTextPositions, AddPath, RemoveObject, InsertFormObjectFromXObject — 는 각각 변경사항을 콘텐츠 스트림으로 직렬화하기 위해 UpdatePage(PDFium의 FPDFPage_GenerateContent)를 호출하기 전에 UnloadTextPage를 호출한다. 이 중 어느 것이든 호출하면 바로 다음의 Text, FindFirst, GetWebLinks 호출은 여러분 쪽에서 별도 호출을 할 필요 없이 지금 상태의 콘텐츠로부터 텍스트 페이지를 새로 만든다

var
  Pdf: TPdf;
  Index: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    Pdf.AddText('Reviewed by J. Alvarez', 'Helvetica', 10, 72, 40, clBlack, 255, 0);
    // AddText already closed the cached text page, so this FindFirst
    // call rebuilds it fresh before it searches
    Index := Pdf.FindFirst('Reviewed by J. Alvarez');
    if Index >= 0 then
      ShowMessage('Stamp confirmed at character ' + IntToStr(Index));
  finally
    Pdf.Free;
  end;
end;

여전히 깨지는 패턴: 원시 TextPage 핸들을 캐시하기

TPdf는 읽기 전용 TextPage 속성을 통해 살아있는 핸들을 노출하는데, 이는 PDFiumPas가 감싸지 않은 FPDFText_* 함수를 호출해야 하는 드문 경우를 위한 것이다. 이 탈출구는 또한 자동 무효화가 도움이 될 수 없는 유일한 곳이다: FPDF_TEXTPAGE 값을 그 속성에서 로컬 변수로 복사해 낸 순간, PDFiumPas는 여러분이 여전히 그것을 붙잡고 있음을 알 방법이 없고, 여러분 코드의 다른 어딘가에서 UnloadTextPage가 실행될 때 여러분의 복사본을 갱신할 방법도 없다

var
  Pdf: TPdf;
  RawHandle: FPDF_TEXTPAGE;
  StaleCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    RawHandle := Pdf.TextPage;    // FPDFText_LoadPage handle, cached in FTextPage
    Pdf.SetText(0, 'Amended Clause 4.2');
    // SetText already closed RawHandle and set Pdf.TextPage back to nil.
    // Calling any FPDFText_* function against the old value now touches a
    // handle PDFium has already freed — undefined behavior, not a bug you
    // can catch with a nil check
    StaleCount := FPDFText_CountChars(RawHandle);
  finally
    Pdf.Free;
  end;
end;

FPDFText_ClosePage가 실행된 뒤 핸들을 사용하는 것은 무시해도 되는 PDFiumPas의 관례가 아니라 PDFium 자체에서의 정의되지 않은 동작이다 — 마지막으로 알려진 데이터를 반환할 수도, 아무것도 반환하지 않을 수도, 프로세스를 크래시시킬 수도 있으며, 특정 빌드에서 그중 무엇이 일어나는지는 애플리케이션 코드가 의존해서는 안 되는 것이다. 안전한 규칙은 좁다: Pdf.TextPage는 그것을 필요로 하는 FPDFText_* 호출 바로 직전에 새로 읽고, 페이지를 편집할 수도 있는 문장을 가로질러 복사본을 절대 붙잡고 있지 말라

편집을 배치로 묶은 뒤 한 번만 조회하라

이는 모든 AddTextRemoveObject 호출 직후마다 결과를 확인하는 방어적 텍스트 조회가 필요하다는 뜻이 아니다. 각 편집 메서드는 이미 텍스트 페이지를 한 번 닫는 비용을 치른다; 루프 안에서 편집마다 조회하는 것은 아무 이득 없이 그 비용을 다시 치르는 것인데, FPDFText_LoadPage는 실행될 때마다 전체 콘텐츠 스트림을 다시 순회하기 때문이다

var
  Pdf: TPdf;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'watermarked.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    // Strip every text object that looks like a draft watermark. Each
    // RemoveObject call already invalidates the cache on its own, so
    // nothing needs refreshing by hand between iterations
    for I := Pdf.ObjectCount - 1 downto 0 do
      if (Pdf.ObjectType[I] = otText) and (Pdf.ObjectBounds[I].Top > 700) then
        Pdf.RemoveObject(I, True);

    // Query once, after the whole batch is done, not once per removal
    if Pdf.FindFirst('DRAFT') < 0 then
      ShowMessage('Watermark cleared');
  finally
    Pdf.Free;
  end;
end;

같은 배치 로직이 검색 상태에 특히 그대로 적용된다. FindNextFindPreviousFindFirst가 시작한 세션을 이어가며, 그 세션은 다른 모든 것과 함께 UnloadTextPage에 의해 해체된다. 그래서 편집 후에 FindFirst를 다시 호출하는 대신 FindNext를 다시 호출하면, 더 이상 존재하지 않는 콘텐츠에 대해 조용히 검색을 재개하는 대신 예외를 일으킨다. 어떤 편집이든 텍스트 콘텐츠와 검색 위치 모두에 대한 확고한 경계로 취급하고, 편집 저편에서 새로 한 번 FindFirst를 호출해 검색을 다시 집어 들게 하라

이것이 추출 및 주석 작업과 어떻게 맞물리는가

순수한 텍스트 추출 — 아무것도 바꾸지 않고 페이지의 텍스트를 읽는 것 — 은 이 문제 전혀 겪지 않는데, 어떤 편집도 건드리지 않은 핸들은 무효화될 일이 없기 때문이다. 수정되지 않은 페이지에서 Text, 문자 사각형, 단어 경계가 어떻게 동작하는지는 PDFiumPas로 텍스트 추출하기에 관한 자매 글이 이 글이 그 위에 더하는 텍스트 페이지 캐시 생명주기 없이 그 영역을 다룬다

이 캐시 생명주기는 편집한 뒤 즉시 그 결과에 대해 무언가를 하는 워크플로에서 가장 중요하다: 수정 사항을 찍고 그것을 검색하는 것, 문단을 삭제 표시하고 그것이 사라졌는지 확인하는 것, 또는 텍스트를 삽입한 직후 그 근처에 마크업 주석을 고정하기 위해 구절을 찾는 것 등이다. 마지막 경우는 따로 짚어둘 가치가 있다 — 쿼드 포인트 마크업 주석은 텍스트 페이지에서 읽어들인 문자 사각형으로부터 위치가 정해지므로, 편집 전에 캡처된 좌표로 만들어진 주석은 편집이 반영되고 나면 엉뚱한 곳을 하이라이트하게 된다

TPdf의 편집 및 텍스트 API는 Delphi와 C++Builder용 PDFium 컴포넌트의 일부이며, 제품 페이지에는 여기서 다룬 편집, 추출, 검색 표면에 대한 전체 메서드 레퍼런스가 실려 있다