기술 문서

PDF 체크박스 플래튼 버그: 델파이의 필드 값 대 위젯

체크박스와 라디오 버튼이 체크되지 않은 상태로 플래튼되는 이유는 외관 상태 /AS가 필드 값 /V와 동기화된 적이 없기 때문입니다. 델파이, C++Builder, Lazarus용 PDFium 기반 VCL 및 LCL 컴포넌트인 PDFium Component는 이제 그 값을 FPDFAnnot_GetFormFieldValue로 읽으며, 이는 위젯 주석이 아니라 부모 필드 딕셔너리를 해석합니다

여기로 이어진 버그 리포트는 처음에는 의심하게 되는 종류입니다. 고객이 서명된 동의서를 플래튼하고 결과를 열면 모든 체크박스가 비어 있습니다. 원본 파일을 Acrobat에서 열면 박스들이 눈에 띄게 체크되어 있습니다. 원본 파일을 같은 컴포넌트로 다시 읽으면 필드 값이 올바릅니다. 오직 플래튼된 출력만이 그것들을 잃으며, 오직 체크박스와 라디오 버튼에 대해서만 그렇습니다: 같은 페이지의 텍스트 필드는 멀쩡하게 나옵니다

플래튼 이후 체크박스가 체크되지 않는 이유는 무엇인가

플래튼은 결코 /V를 보지 않기 때문입니다. FPDFPage_Flatten은 위젯 외관 스트림을 페이지 콘텐츠에 구워 넣으며, 그것이 고르는 외관은 /AS가 이름 붙인 것입니다. /AS가 여전히 /Off라고 말하는데 필드 값은 박스가 켜져 있다고 말한다면, 플래튼은 충실하게 꺼진 외관을 구워 넣습니다. 값은 결코 손실된 적이 없습니다. 그것은 결코 참조된 적이 없었습니다

ISO 32000-1 §12.5.5는 세 개의 가능한 항목 /N, /R, /D를 가진 외관 딕셔너리 /AP를 정의합니다. 체크박스나 라디오 버튼의 경우 /N 항목은 스트림이 아니라 키가 외관 상태 이름인 서브딕셔너리이며, §12.5.2는 /N이 서브딕셔너리일 때 /AS를 필수 선택자로 만듭니다. 그래서 체크박스는 미리 만들어진 두 개의 외관과 포인터 하나를 갖고 있습니다. 그 포인터를 틀리면 렌더링은 아무리 올바른 /V로도 고칠 수 없는 방식으로 틀립니다. 이것이 또한 실패 모드가 텍스트 필드와 다른 이유이기도 합니다. 텍스트 필드는 애초에 선택할 미리 만들어진 외관이 전혀 없습니다: 텍스트 필드 /N은 값이 바뀐 뒤 처음부터 재생성되어야 하는 단일 스트림이므로, GenerateFormAppearances는 이 두 경우를 완전히 별개의 코드 경로로 처리하며 버튼 경로만 고장 나 있었습니다

체크박스 값은 실제로 어디에 사는가

위젯이 아니라 필드 딕셔너리에 있습니다. ISO 32000-1 §12.7.5.2는 체크박스와 라디오 버튼을 /V가 현재 외관 상태를 이름 붙이는 이름 객체인 버튼 필드로 설명하며, §12.7.3.1은 /V를 모든 필드 딕셔너리에 공통된 항목들 사이에 둡니다. §12.5.6.19에 정의된 위젯 주석은 /AS와 /AP를 제공합니다. 스펙의 그 무엇도 위젯이 /V를 가져야 한다고 강제하지 않습니다

// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValue에는 결함이 없습니다. 그 계약은 정확히 이름이 말하는 그대로입니다: 여러분이 건넨 주석 딕셔너리에서 문자열 항목을 가져오는 것입니다. 객체 13에서 /V를 요청하면 아무것도 돌아오지 않는데, 이는 객체 13이 진짜로 /V를 갖고 있지 않기 때문입니다. 결함은 호출자 쪽에 있었으며, ISO 32000-1이 결코 약속한 적 없는 평평한 객체 모델을 가정했던 것입니다

필드와 위젯이 하나의 딕셔너리를 공유하는 경우는 언제인가

필드가 정확히 하나의 위젯을 가질 때입니다. §12.5.6.19는 필드 딕셔너리와 그 단일 위젯 주석이 하나의 객체로 병합되는 것을 허용하며, 대부분의 저작 도구는 그 지름길을 취합니다. 병합된 객체에서는 /FT, /T, /V, /AS, /AP가 모두 나란히 앉아 있으므로, 위젯 수준의 /V 읽기가 성공하고 버그 전체가 보이지 않게 남습니다

필드가 두 개 이상의 위젯을 소유하는 순간 병합은 불가능해지며, §12.7.3.1은 위젯들이 별도의 필드 딕셔너리의 /Kids가 될 것을 요구합니다. 모든 라디오 그룹은 구조적으로 이 형태입니다. 헤더와 바닥글에 반복되는 동의 체크박스도, 저작 도구가 두 번째 페이지로 복사한 어떤 필드도 마찬가지입니다. 이것이 바로 이 결함이 회귀 스위트에서 살아남은 이유 전체입니다: 테스트 코퍼스는 단일 위젯 폼으로 가득했고 고객 파일은 그렇지 않았습니다. 컴포넌트에 의존하지 않고 직접 위젯을 순회한다면, 같은 비대칭이 열거 순서에서도 나타나며, PDFium Component를 사용한 PDF 폼 필드 탐색에 관한 노트는 페이지 수준의 주석 순회가 문서 수준의 필드 트리와 어떻게 관련되는지 다룹니다

PDFium이 의도한 방식으로 값 읽기

FPDFAnnot_GetFormFieldValue가 올바른 API이며, 체크박스 경로가 그것을 사용하지 않은 채로 컴포넌트에 한동안 바인딩되어 있었습니다. 그것은 주석뿐 아니라 폼 핸들도 받는데, 이것이 중요한 신호입니다: 폼 필 환경이 사용 가능하면 PDFium은 주석을 그 폼 컨트롤로 해석하고 필드 객체로부터 값을 읽으므로, 병합된 레이아웃과 분리된 레이아웃 모두에 대해 올바른 답을 반환합니다

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

그 스니펫에서 두 가지 세부 사항은 틀리기 쉽습니다. 반환된 길이는 종료자를 포함한 UTF-16 텍스트의 바이트 개수이므로, 문자 개수는 buflen div 2 - 1이며 값 2는 빈 문자열을 뜻합니다. 그래서 가드 buflen >= 4는 최소한 하나의 실제 문자를 뜻하며, 이는 /V가 전혀 없는 필드의 /AS가 빈 이름으로 덮어써지는 것을 막아줍니다

/AS와 /AP /N이 실제로 합의하는 것은 무엇인가

그것들은 이름 하나에 합의하며, 그 이름은 파일을 생성한 쪽이 고릅니다. §12.7.5.2는 꺼진 상태가 /Off로 불려야 한다고 요구하고, 켜진 상태는 전적으로 생산자에게 맡깁니다. /Yes는 규칙이 아니라 관례입니다. Acrobat은 /Yes를 쓰지만, 많은 생성기가 /On, /1, /Choice1, 또는 지역화된 단어를 쓰며, 라디오 그룹은 보통 각 자식에게 서로 다른 켜짐-상태 이름을 주어 그룹이 어떤 버튼이 선택되었는지 표현할 수 있게 합니다. 이것이 바로 /V를 그대로 /AS에 복사하는 것이 편법이 아니라 올바른 연산인 이유입니다: 체크된 컨트롤에 대해 PDFium은 파일 자신이 정의한 켜짐-상태 이름을 보고하고, 체크되지 않은 것에 대해서는 Off를 보고하므로, 여러분이 /AS에 쓰는 값은 그 위젯의 /AP /N 서브딕셔너리에 존재하는 키임이 보장됩니다. /Yes를 하드코딩하면 Acrobat 출력에서는 작동하겠지만 다른 모든 곳에서는 조용히 깨질 것입니다

연산 순서, 그리고 여전히 주의가 필요한 곳

순서는 고정되어 있고 용서가 없습니다: 폼 필을 활성화하고, 값을 할당하고, 외관을 재생성하고, 플래튼한 다음, 저장합니다. 재생성 단계를 건너뛰면 FPDFPage_Flatten은 비어 있거나 오래된 외관 스트림을 찾아 아무 불평 없이 그것을 구워 넣는데, 이는 오류 반환이 아니라 조용한 데이터 손실입니다

Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

두 가지 정직한 한계가 남아 있습니다. 첫째, 동기화는 필드 값을 그 필드의 모든 위젯의 /AS에 쓰는데, 이는 체크박스에는 올바르지만 각 자식이 자신만의 켜짐-상태 이름을 정의하는 라디오 그룹에는 근사치입니다. /AP /N에 쓰여진 /AS와 일치하는 항목이 없는 자식은 §12.5.5 아래에서 선택할 외관이 없으므로, 선택되지 않은 버튼이 빈 원 대신 아무것도 아닌 것으로 플래튼될 수 있습니다. 플래튼 전에 FPDFAnnot_GetFormControlIndex로 라디오 그룹을 감사하는 것은 몇 줄의 가치가 있습니다. 둘째, 이 중 그 무엇도 XFA에는 적용되지 않습니다. XFA에서는 값이 AcroForm 딕셔너리가 아니라 XML 데이터 패킷 안에 살며, 그 구분은 지속되지 않는 XFA 필드 편집에 관한 노트에서 다룹니다. 이 하나의 수정을 넘어서도 새겨둘 가치가 있는 일반적인 교훈이 있습니다: API가 주석뿐 아니라 폼 핸들도 받을 때는 필드 계층 구조를 여러분 대신 해석해주겠다고 말하고 있는 것이며, 오직 주석만 받을 때는 여러분이 건넨 바로 그 객체를 정확히 읽겠다는 뜻입니다. 그 구분은 데이터 교환도 지배합니다. XFDF 폼 데이터 내보내기 및 가져오기는 위젯 위치가 아니라 완전한 자격을 갖춘 필드 이름으로 작동하기 때문입니다

폼 플래트닝은 단일 API 호출처럼 보이지만 세 개의 딕셔너리 사이의 계약임이 드러나는 그런 기능 중 하나입니다. 이미 그 계약을 인코딩해 놓은 컴포넌트로 작업하고 싶다면, 델파이와 C++Builder용 PDFium Component는 여기서 설명한 외관 재생성, 플래트닝, 폼 필드 접근을 평범한 속성과 메서드로 제공합니다