기술 문서

PDF 폼 필드 값이 되돌아가는 이유: Delphi와 direct 필드

PDF 양식 필드 값이 '되돌아가는' 것은 필드가 direct 사전으로 저장돼 있을 때 일어난다. PDFium이 페이지 /Annots 배열의 사본을 indirect 객체로 승격하는 동안 /Fields는 두 번째 direct 사본을 간직한다. Delphi용 PDFium Component는 타입화된 필드 쓰기를 처리할 때 쓰기 전에 두 참조를 하나의 indirect 소유자로 통합한다

증상은 대개 유령 소설처럼 읽힌다. 쓰기는 True를 돌려주는데 바로 다음 읽기가 옛 값을 돌려준다. 저장하고 다시 열어도 옛 값이 그대로다. 아무것도 예외를 던지지 않았고, 소유한 모든 뷰어에서 PDF는 완벽히 평범해 보인다

증상: 자리 잡지 못하는 성공한 쓰기

실패 패턴은 좁지만 매우 재현 가능하다. 필드 사전이 번호 붙은 indirect 객체가 아니라 인라인(direct 객체)으로 기록된 양식에서만 나타난다. 대다수 저작 도구는 indirect 필드를 내놓으므로 문제는 몇 달을 숨어 있다가 특정 생성기의 파일에서 나타난다. 흔히 /AcroForm 전체를 인라인으로 기록하는 손작성 익스포터가 범인이다

관찰 둘이 겹치면 원인을 가리킨다. 쓰기가 성공을 보고하고, 폼 계층을 통한 필드의 네이티브 읽기는 이전 값을 돌려준다. 둘 다 보이면 문자열 인코딩 의심을 접고 객체 소유권을 들여다봐라

성공한 쓰기 뒤에 PDF 양식 필드 값이 되돌아가는 이유는?

PDF 양식 필드 값이 되돌아가는 이유는 별개의 두 사전이 모두 같은 필드라고 주장하고, 쓰기는 필드 트리가 참조하지 않는 사본을 갱신하기 때문이다. 필드의 값은 /V 항목에 살고, ISO 32000-1 §12.7.3.1이 그 항목이 어디서 권위를 갖는지 정의한다. /AcroForm /Fields에서 닿는 필드 사전, /Kids를 타고 내려가는 터미널 필드, 상속 항목은 /Parent에서 풀리는 것이다

같은 조항이 모든 소란을 낳는 편의를 허용한다. 필드가 정확히 하나의 위젯 어노테이션을 가질 때 필드 사전과 위젯 어노테이션 사전을 단일 사전으로 합쳐도 된다. 잘 구성된 파일에서 그 병합 사전은 하나의 indirect 객체로 두 곳에서 참조된다. /Fields(또는 부모의 /Kids)에서 한 번, 페이지의 /Annots 배열에서 한 번. 두 참조, 하나의 객체, 하나의 /V다

하나의 사전의 두 사본

ISO 32000-1 §7.3.10은 공유될 수 있는 정체성을 지닌 것은 indirect 객체뿐임을 명백히 말한다. direct 사전에는 객체 번호가 없으므로, 생성기가 병합된 필드·위젯 사전을 /Fields와 /Annots 양쪽에 인라인으로 기록하면 파일에는 두 번 참조되는 하나의 객체가 아니라 우연히 같은 키와 값을 지닌 똑같이 생긴 사전 둘이 들어 있다

PDFium은 이를 관용하고, 그렇게 함으로써 갈라짐을 영구화한다. 어노테이션 핸들이 필요할 때 PDFium은 페이지 /Annots 배열의 direct 위젯 사전을 indirect 객체로 승격할 수 있지만, 동등한 direct 사본은 /AcroForm /Fields(또는 부모의 /Kids) 안 제자리에 남는다. 그 순간부터 문서는 하나의 논리 필드에 두 소유자를 지닌다. 페이지 어노테이션의 /V를 고치는 라이터는 사전 수준에서 진짜 성공을 얻는다. 폼 계층과 이후의 모든 리더가 진실의 원천으로 취급하는 필드 트리는 그 변화를 결코 보지 못한다

낡은 값이 저장과 재열기를 살아남는 이유가 바로 이것이다. 새 값은 필드 계층이 소유하지 않는 객체에 성실하게 직렬화됐다

Delphi의 PDFium Component: 두 direct 사전이 하나의 필드를 주장하고, 쓰기는 승격된 어노테이션을 때리며, 필드 트리는 여전히 옛 /Fields 사본을 읽는다
쓰기는 필드 계층이 소유하지 않는 객체에 성실하게 직렬화됐으므로, 낡은 값은 저장과 재열기를 살아남는다

PDFium Component가 단일 필드 소유자를 회복하는 방식

PDFium Component는 모든 타입화된 쓰기 전에 direct 필드 소유권을 정규화한다. direct /AcroForm과 필드 사전을 indirect 객체로 승격하고, 각 direct 위젯을 자기에 맞는 페이지 어노테이션에 묶으며, /Fields, /Kids, /Parent, /Annots를 다시 써서 모든 참조가 같은 indirect 객체를 가리키게 한다. 그다음에야 /V를 바꾸므로 필드 값, 선택된 위젯, 저장된 문서가 하나의 소유자를 공유한다

어려운 부분은 정체성이다. 내용이 동일한 direct 사전 둘이 같은 위젯임을 증명하지 않는다. 양식에는 생김새가 같은 위젯 둘이 합법적으로 있을 수 있다. 그래서 정규화기는 추측을 거부하고 각 direct 위젯에 명시적이고 유일한 /NM 어노테이션 이름(ISO 32000-1 §12.5.2)을 요구한다. 쓰기 경로가 적용하는 규칙은 엄격하다

  • direct 위젯에는 최대 1024 UTF-16 유닛의 비어 있지 않은 /NM이 필요하고, 같은 이름을 지닌 페이지 어노테이션은 정확히 하나여야 한다
  • direct 사본과 페이지 어노테이션은 /Parent와 PDFium이 재생한 /AP를 빼면 동일하게 직렬화돼야 한다. 그 밖의 차이는 쓰기를 거부시킨다
  • 부모 링크 둘은 따로 검사되므로, 페이지 어노테이션의 올바른 /Parent가 원본 direct 사본의 상충하는 것을 덮을 수 없다
  • 빠진 /Parent는 유일한 필드 트리 소유권이 답을 모호하지 않게 만들 때만 공급되고, 동등한 direct 부모 사본은 승격된 소유자로 다시 묶인다
  • 중복된 완전 한정 필드 이름, 순환하거나 소유자가 여럿인 필드 트리, 서명 필드는 무엇이든 발행되기 전에 거부된다
  • 같은 페이지의 링크 같은 무관한 어노테이션과 무관한 /AcroForm 항목은 그대로 유지된다

정규화기는 또한 빡빡한 예산 아래 돈다. 필드 트리 방문·페이지·배열 멤버 4096, 중첩 깊이 64, 새 indirect 객체 256, 승격 사전당 1 MiB, 승격 바이트 합계 4 MiB, 들여다본 사전 바이트 128 MiB, 그리고 전체 필드 이름당 4096 UTF-16 유닛과 폼 전체로 262144. 적대적이거나 깨진 파일은 예산에 걸려 오류 메시지로 실패한다. 폼 채움이 서비스 거부로 변하지 않는다

전체 연산은 트랜잭션적이다. TPdf.SetFormFieldValues는 스냅샷을 캡처하고, 정규화된 객체와 새 값을 담은 증분 업데이트를 만들고, 그 후보를 로드하며, 후보가 검증될 때만 살아 있는 문서를 맞바꾼다. 정규화가 파일을 거부하거나 배치의 이후 요청이 실패하면 원본 문서와 그 값들, 논리 필드 커서는 정확히 그대로 남는다

Delphi용 PDFium Component가 direct AcroForm 필드 소유권을 통합하는 방식: 스냅샷, NM 정체성 검사, 참조 통합, 값 쓰기, 명시적 거부 경로를 지닌 검증된 발행
direct 필드 사전은 /V가 바뀌기 전에 승격되어 하나의 indirect 소유자로 다시 묶이고, 모호한 소유권은 깨끗하게 거부된다

Delphi에서 direct 필드 값 쓰고 검증하기

안전한 패턴은 쓴 뒤 네이티브 폼 계층으로 읽어 되돌리는 것이고, 쓰기가 True를 돌려줬어도 불일치는 실패로 치는 것이다. TPdf.SetFormFieldValue는 현재 페이지에서 인덱스로 필드를 주소 지정하고, 타입화된 TPdfFormValue를 받고, TPdfFormValueWriteReport를 채운다. TPdf.FormField[Index]는 PDFium의 폼 계층을 통해 값을 읽어 되돌리는데, 위젯을 믿는 대신 필드 트리를 푼다

Delphi와 PDFium Component에서 PDF 양식 필드의 쓰기·검증 패턴: 타입화된 SetFormFieldValue, FormField로 읽어 되돌리기, 그다음 저장과 재열기 검사
True 돌려주기만으로는 사전이 바뀌었다는 것만 증명한다 — 필드 트리로 읽어 되돌리고 저장과 재열기를 검사해라
uses
  System.SysUtils, PDFium;

procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
  const NewText: WString);
var
  Value: TPdfFormValue;
  Report: TPdfFormValueWriteReport;
begin
  Pdf.PageNumber := PageNo;          // SetFormFieldValue는 현재 페이지를 겨냥한다
  Value := Default(TPdfFormValue);
  Value.Kind := fvkText;
  Value.Text := NewText;

  if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
    raise Exception.CreateFmt('Field %d on page %d rejected: %s',
      [FieldIndex, PageNo, Report.ErrorMessage]);

  // 자기 변수가 아니라 필드 트리와 대조해 검증한다
  if Pdf.FormField[FieldIndex] <> NewText then
    raise Exception.CreateFmt('Field "%s" did not keep the new value',
      [Pdf.FormFieldInfo[FieldIndex].Name]);
end;

소유권을 풀 수 없는 파일, 이를테면 /NM 없는 direct 위젯에서는 SetFormFieldValue가 False를 돌려주고 Report.UpdatedWidgets는 0이며 Report.ErrorMessage가 "Direct widgets require an explicit unique NM identity" 같은 문제를 이름으로 밝힌다. 원하는 동작이 바로 이것이다. 조용한 반쯤 쓰기 대신 명확한 거부. 전제 조건 하나가 더 알아 둘 만하다. 타입화된 쓰기는 어노테이션 전용이고 네이티브 포커스를 얻지 않으므로, 네이티브 필드 편집기가 여전히 포커스를 쥐고 있으면 전체 트랜잭션이 스냅샷 캡처 전에 거부된다. 루프로 재시도하는 대신 Pdf.ClearFormFieldFocus를 일부러 먼저 호출하라

필드가 여럿이거나 특히 페이지를 넘나든다면 배치 형태를 써라. TPdf.SetFormFieldValues는 요청 1부터 256개를 받고, 전부를 하나의 후보에 적용하며, 전부 아니면 전무로 발행한다. TPdfFormValuesWriteReport.FailedRequest가 배치를 깨뜨린 요청을 알려 준다

procedure FillInvoiceHeader(Pdf: TPdf);
var
  Requests: TPdfFormValueRequests;
  Batch: TPdfFormValuesWriteReport;
begin
  SetLength(Requests, 2);
  Requests[0] := Default(TPdfFormValueRequest);
  Requests[0].Page := 1;
  Requests[0].FieldIndex := 0;
  Requests[0].Value.Kind := fvkText;
  Requests[0].Value.Text := 'ACME Tooling Inc.';

  Requests[1] := Default(TPdfFormValueRequest);
  Requests[1].Page := 2;
  Requests[1].FieldIndex := 0;
  Requests[1].Value.Kind := fvkText;
  Requests[1].Value.Text := '2026-10-27';

  if not Pdf.SetFormFieldValues(Requests, Batch) then
    raise Exception.CreateFmt('Request %d failed: %s',
      [Batch.FailedRequest, Batch.ErrorMessage]);
  // Batch.UpdatedFields = 2; Batch.UpdatedWidgets가 동기화된 모든 위젯을 센다
end;

같은 터미널 필드를 주소 지정하는 요청 둘은 마지막 쓰기가 이기게 두는 대신 중복 소유자로 거부되고, 원본 문서는 보존된다. checkbox와 radio 값도 같은 규칙을 따른다. 버튼의 /V와 /AS 분업은 PDFium에서 checkbox 필드 값과 위젯 상태가 다룬다

값이 저장과 재열기를 살아남는다는 것 증명하기

같은 세션의 읽기 되돌리기는 살아 있는 문서가 응집돼 있음을 증명한다. 저장과 재열기는 파일이 그렇다는 것을 증명하며, 검사는 싸서 여러분 통합 테스트에 유지할 만하다

procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
  PageNo, FieldIndex: Integer; const Expected: WString);
var
  Reopened: TPdf;
begin
  if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
    raise Exception.Create('Save failed');

  Reopened := TPdf.Create(nil);
  try
    Reopened.FileName := Target;
    Reopened.Active := True;
    Reopened.PageNumber := PageNo;
    if Reopened.FormField[FieldIndex] <> Expected then
      raise Exception.Create('Saved file lost the field value');
  finally
    Reopened.Free;
  end;
end;

바이트 수준 함정 둘 더: Trim과 트레일러 /ID

direct 필드 정규화는 날 사전 바이트를 다시 읽는다는 뜻이며, PDF 문법을 만지는 Delphi 코드에 관련 있는 작은 함정 둘이 나온다

첫 번째는 AnsiString에 담긴 날 PDF 바이트에 대한 SysUtils.Trim이다. choice 옵션 같은 리터럴 문자열은 FE FF 바이트 순서 마크와 NUL 바이트를 포함한 UTF-16BE 텍스트를 합법적으로 담을 수 있다. 유니코드 Delphi에서 SysUtils.Trim은 string을 받으므로, 호출이 AnsiString을 활성 ANSI 코드 페이지로 UTF-16 변환했다가 결과를 되돌린다. 그 코드 페이지에 따라 왕복은 상위 바이트를 다시 쓰고, BOM을 훼손하고, 뒤따르는 구분자까지 흔들 수 있다. 안전한 접근은 어휘 분석기를 바이트 위에 두는 것이다. PDF 공백은 위치로 건너뛰고(ISO 32000-1 §7.2.2가 NUL, tab, LF, FF, CR, 공백을 나열한다) Copy로 잘라내며, 값이 PDF 텍스트 문자열로 확인된 뒤에야 디코딩한다

// 날 PDF 바이트에는 위험: AnsiString -> string -> AnsiString (W1057/W1058)
Token := Trim(Token);

// 바이트 안전: PDF 공백을 위치로 건너뛴 뒤 잘라낸다
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
  Inc(P);
Token := Copy(Token, P, MaxInt);

두 번째 함정은 증분 업데이트 자체에 앉아 있다. ISO 32000-1 §7.5.5(Table 15)와 §14.4는 트레일러 /ID가 두 바이트 문자열의 배열이기를 요구한다. sparse 업데이트 식별자를 조립하는 내부 헬퍼는 /ID 키나 배열 괄호 없이 두 문자열의 내용만 돌려주며, 트레일러는 그것을 명시적으로 /ID [ ... ]로 감싸야 한다. PDFium의 관대한 로더와 이후의 전체 재저장은 빠진 감싸개를 숨기므로, 믿을 만한 검사는 독립적 엄격 파서로 후보 파일과 저장 파일을 파싱해 16바이트 문자열 둘을 찾는 것이다. 증분 업데이트를 직접 쓴다면 같은 함정이 적용된다. 암호화 PDF의 증분 업데이트 글이 트레일러 사슬을 더 깊이 다룬다

여러분 자신의 Delphi 폼 코드에서는 무엇을 해야 하는가?

  • 쓰기 뒤에는 언제나 FormField[Index]로 값을 읽어 되돌리고, 테스트에서는 저장, 재열기, 재읽기까지 하라
  • False와 ErrorMessage는 정보로 취급하라. NM, 부모 상충, 중복 이름을 지목하는 거부는 파일의 소유권이 실제로 모호하다는 뜻이다
  • PDF를 생성한다면 필드와 위젯을 indirect 객체로 내놓고 모든 위젯에 유일한 /NM을 주어라. 모호함이 원천에서 제거된다
  • 멀티 필드나 페이지 간 채움에는 SetFormFieldValues를 써라. 늦은 실패가 절반만 갱신된 폼을 남길 수 없다
  • AnsiString에 담긴 날 PDF 바이트 위에 Trim이나 다른 string RTL 헬퍼를 절대 돌리지 마라

경계도 분명히 하라. 서명된 문서, XFA 양식, 암호화된 스냅샷, 위젯 소유권을 유일하게 풀 수 없는 파일은 선언된 프로파일 밖에 남아 강제로 통과시키는 대신 거부된다. 워크플로가 필드를 문서 사이로 옮긴다면 소유권 문제는 더 어려워진다. PDF 문서 사이에서 AcroForm 필드 접목하기가 그 사례를 다룬다

PDFium Component는 이 타입화되고 트랜잭션적인 폼 쓰기를 렌더링, 텍스트 추출, 문서 편집과 함께 Delphi, C++Builder, Lazarus 애플리케이션에 가져온다. 버전 이력, 라이선스, 내려받기는 PDFium Component for Delphi 제품 페이지에 있다