HotPDF Delphi Component는 불러온 AcroForm 필드의 /FT, /Ff, /V, /DV를 상속 가능한 속성으로 취급하며 /Parent 체인을 따라 걸어 해석합니다. v2.754.3과 v2.754.4부터 타입을 부모에서 물려받은 이름 있는 자식도 개별적으로 주소할 수 있고, RemoveFormField는 형제 필드를 그대로 두며, ResetLoadedFormField는 상속된 기본값을 원래 PDF 오브젝트 타입 그대로 복사합니다. 그 전에는 평범한 폼이 의외로 많이 잘못 읽히고 있었습니다
이 모든 걸 드러내는 폼은 특이한 게 아닙니다. 저작 도구가 /FT /Ch와 필드 플래그, 옵션 목록을 한 번만 실은 그룹 노드 group을 만들고 그 아래 이름 있는 자식 a와 b 두 개를 매다는데, 각각은 /T, /Parent, /Rect, 자기 /V만 가진 필드 더하기 위젯 병합 딕셔너리입니다. 속성을 공유하는 완전히 합법적인 방법이고, Delphi로 불러온 PDF의 폼 필드 값 설정하기의 Limits 절이 미처리로 표시했던 바로 그 케이스입니다. 버튼 조정이 로컬 /FT만 보고 있었으니까요. 이 글은 그 글이 멈춘 지점에서 이어서 필드 트리의 분류 방식, 상속 값의 읽기 방식, 단일 필드 리셋이 기록할 수 있는 것을 다룹니다
필드는 부모로부터 어떤 AcroForm 엔트리를 상속할 수 있을까?
ISO 32000-1 §12.7.3.1 표 220은 /FT, /Ff, /V, /DV를 상속 가능으로 표시하고, §12.7.4.3의 표 229는 텍스트 필드의 /MaxLen도 마찬가지로 둡니다. 그러니 로컬 딕셔너리만 보는 리더는 완전히 유효한 자식에게 잘못된 타입, 잘못된 플래그, 빈 값을 보고합니다. HotPDF는 이 모든 읽기를 내부 리졸버 하나, HPDFLoadedInheritedFieldObject로 통합니다. 이 리졸버는 딕셔너리에서 키를 검사하고, 간접 참조가 있으면 해석하며, 그렇지 않으면 /Parent를 최대 128수준까지 따라갑니다. 잘못된 파일이 /Kids와는 상관없는 /Parent 사이클을 만들 수 있기 때문입니다. 공개 getter들이 그 위에 얹힙니다. GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue, 그리고 부모에 저장된 /Opt 배열도 집어 오는 옵션 헬퍼 GetLoadedFormFieldOptionCount와 GetLoadedFormFieldOptions입니다. 리졸버에서 실수하기 쉬운 규칙이 하나 있습니다. 걷기는 키를 담은 첫 번째 딕셔너리에서 멈춘다는 것, 거기 값이 빈 문자열이라도요. 로컬 /V ()는 부모를 가리는 의도된 오버라이드지, 더 위에서 채워 넣을 빈틈이 아닙니다
var
Pdf: THotPDF;
Field: THPDFLoadedFormField;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
// 'group'은 /FT /Ch, /Ff 131078, /Opt를 실고 자식
// 'group.b'는 /T, /Parent, /Rect, 자기 /V만 가집니다
Field := Pdf.GetFormField('group.b');
try
if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
begin
// 131078 = Combo (bit 18) + NoExport (bit 3) + Required (bit 2)
Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
Writeln(Pdf.IsFormFieldRequired(Field.Index)); // TRUE
Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
Writeln(Pdf.GetFormFieldValue(Field.Index)); // 로컬 /V
end;
finally
Field.Free;
end;
finally
Pdf.Free;
end;
end;
로컬 /FT는 단말 필드 판정으로 틀린 이유는?
부모가 타입을 공급하면서도 이름 있는 자식 필드를 소유할 수 있으므로, /FT의 존재는 필드 트리가 어디서 끝나는지 아무것도 말해 주지 않습니다. 예전 순회는 노드가 자체 /FT를 갖거나 /Kids가 없으면 단말로 선언했습니다. 위 폼에서 group은 /FT /Ch와 /Kids를 둘 다 갖고 있으므로, 위젯 두 개를 가진 group이라는 필드 하나로 등록됐고, 완전 정규화된 이름 group.a와 group.b는 그냥 사라졌습니다. GetFormFieldCount는 1을 반환하고, 자식 이름으로 찾기는 실패하고, SetFormFieldValue는 공유 부모에만 쓸 수 있었습니다. 대체 테스트인 HPDFLoadedFieldHasChildFields는 부모 대신 자식들을 봅니다. kid가 자체 /T를 갖거나, 자체 /Kids를 갖거나, 아예 /Subtype /Widget 딕셔너리가 아니면 그 kid는 자식 필드입니다. 어떤 kid도 해당되지 않을 때만 노드가 단말이고, 그 kid들은 위젯 어노테이션으로 취급됩니다
그 규칙을 빚어낸 두 엣지 케이스는 모두 병합 딕셔너리에서 나왔습니다. §12.7.3.1은 필드가 위젯 하나만 가질 때 병합을 허용합니다. 이름 있는 병합 딕셔너리는 /Subtype /Widget을 실으면서도 여전히 자식 필드이므로, subtype만으로 그것을 부모의 익명 위젯 목록으로 보낼 수 없습니다. /T가 이깁니다. 반대도 일어납니다. 일부 생산자는 부모의 /FT를 모든 익명 위젯에 반복하므로, /FT를 위젯이 새 필드를 시작한다는 증거로 쓸 수도 없습니다. 이 분류는 관계 캐시, FormFieldExists, RemoveFormField가 공유하고, 그 각각의 순회는 이제 이미 방문한 딕셔너리를 기록하고 128수준을 넘으면 멈춥니다. 그룹이 자기 자신을 두 번 나열하는 회귀 파일, /Kids [5 0 R 5 0 R 6 0 R 7 0 R]은 영원히 재귀하거나 같은 노드를 두 번 세는 대신 정확히 필드 둘을 보고합니다
RemoveFormField는 형제 필드 삭제를 어떻게 피할까?
RemoveFormField는 이제 이름을 지정한 자식만 삭제합니다. 발견과 삭제가 드디어 단말 필드가 무엇인지 같게 보기 때문입니다. 이 합의는 보이는 것보다 중요합니다. 이름 기반 오버로드는 관계 캐시로 인덱스를 해석한 다음, /AcroForm /Fields를 두 번째로 걸으며 단말 필드를 셉니다. 캐시가 group.a와 group.b를 보도록 고쳐진 뒤에도 삭제 순회가 고쳐지지 않았다면 group을 여전히 단말 필드 하나로 취급하고, 인덱스 0은 부모를 모든 형제와 그들의 위젯과 함께 지워 버렸을 것입니다. 삭제 순회는 이제 같은 HPDFLoadedFieldHasChildFields 테스트와 같은 방문 집합을 쓰고, 제거된 자식의 위젯 어노테이션만 수집해 각 페이지의 /Annots에서 벗겨 내며, 부모는 /Kids 배열이 비게 될 때만 제거합니다. 회귀 테스트는 실수가 드러날 세 곳을 모두 검사합니다. 부모의 /Kids, 페이지 /Annots, 살아남은 형제의 값과 외양이며, 전체 재작성 후와 증분 업데이트 후 모두에서 그렇습니다
// 이름 있는 자식 하나 제거, 형제와 공유 부모는 살아남습니다
Pdf.RemoveFormField('group.a');
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// 타입, 플래그, 옵션은 여전히 부모를 통해 해석됩니다
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');
기본값이 상속됐을 때 ResetLoadedFormField는 무엇을 쓸까?
ResetLoadedFormField는 상속된 /DV의 새 사본을 같은 PDF 오브젝트 타입으로 로컬 /V에 기록하고, 필드를 건드리기 전에 기본값 전체를 검증합니다. 오브젝트 타입이 중요한 이유는 스칼라 getter가 모든 것을 텍스트로 평탄화하기 때문입니다. 체크박스 기본값은 /Yes 같은 name이고, 멀티셀렉트 리스트 박스 기본값은 문자열 배열이며, 텍스트 기본값은 16진수 UTF-16 문자열일 수 있습니다. 이것들을 GetLoadedFormFieldDefaultValue로 복사하면 name은 문자열로, 배열은 빈 문자열로, 16진수 문자열은 그 리터럴 숫자들로 변해 버립니다. 그래서 리셋은 상속된 타입으로 분기합니다. 텍스트와 choice 필드는 IsHexadecimal 플래그를 유지하는 새 문자열 오브젝트를 받고, 배열 기본값을 가진 choice 필드는 새 문자열들의 새 배열을 받으며, 푸시버튼이 아닌 버튼은 새 name 오브젝트를 받습니다. 부모의 오브젝트를 가리키는 대신 복사하는 건 의도입니다. 부모의 /DV 배열이나 그 오브젝트 번호를 공유하는 /V는 누군가 나중에 값을 편집할 때 기본값을 바꿔 버립니다. 타입이 틀린 기본값, 문자열 외의 것을 담은 choice 배열은 예외를 일으키고 /V와 /I를 그대로 남겨 둡니다. 값이 없는 푸시버튼(표 226, 비트 17)과 시그니처 필드는 예전의 문자열 전용 경로로 폴백합니다
체인 어디에도 /DV가 없을 때는 로컬 빈 문자열, 체크박스나 라디오 필드에는 /Off를 써서 지우기 계약을 유지합니다. 로컬 /V를 삭제하는 쪽이 더 깔끔해 보이지만 틀립니다. 부모가 현재 값을 갖고 있을 수 있고, 자식의 오버라이드를 제거하면 그 값이 조용히 돌아옵니다. 이것이 단일 필드 리셋이 §12.7.5.3의 ResetForm 액션이 아닌 이유이기도 합니다. ResetForm은 사용자가 버튼을 클릭할 때 뷰어가 필드 집합에 대해 실행하는 액션으로, HotPDF로 AcroForm 필드와 액션 만들기에서 다룹니다. ResetLoadedFormField는 불러온 필드 하나에 대한 편집 연산이고, 기본값 없음 케이스에는 자기 규칙을 갖으며, NoteLoadedFormFieldDirty로 필드를 기록해 증분 재계산이 변경을 보게 합니다
var
Field: THPDFLoadedFormField;
begin
Field := Pdf.GetFormField('group.a');
try
// 부모가 MultiSelect 리스트 박스에 /DV [(b) (r)]을 보유: group.a는
// 자기 /V [(b) (r)]과 새 /I [0 2]를 얻습니다. 부모는 그대로
Pdf.ResetLoadedFormField(Field.Index);
// 스칼라 getter는 배열 기본값을 표현할 수 없습니다
Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // 비어 있음
finally
Field.Free;
end;
Pdf.SaveLoadedDocument('survey-reset.pdf');
end;
/V, /I, /AS의 합의 유지
리셋은 선택 인덱스와 외양 상태가 값을 따라올 때만 올바르므로, ResetLoadedFormField는 SetFormFieldValue와 같은 두 조정기로 마무리합니다. HPDFReconcileChoiceSelection은 이제 배열 값을 받습니다. 로컬 /I를 변형하지 않고 삭제하고, 모든 값을 각 /Opt 엔트리의 export 절반과 대조한 다음, 정렬된 새 /I 하나를 기록합니다. 그래서 옵션 b, g, r에 대해 [(b) (r)]로 리셋하면 /I [0 2]가 나옵니다. ReconcileLoadedButtonAppearanceStates는 이제 상속된 타입을 요청하므로, /FT /Btn이 부모에 사는 자식 체크박스가 드디어 /AS를 설정받습니다. 쓰기 쪽에서는 SetFormFieldValue와 SetLoadedFormFieldDefaultValue가 상속된 푸시버튼 아닌 버튼에 name 오브젝트를 저장합니다. 타입을 복사할 로컬 엔트리가 자식에게 없을 때도요. 그리고 EnsureLoadedFieldAppearanceStream이 버튼 외양을 다시 만들 때는 값이 on 상태와 일치하지 않으면 /AS /Off를 쓰고, 각 상태 스트림에 제대로 된 /Type /XObject, /Subtype /Form, /BBox를 부여합니다. v2.754.4 이전에는 리셋 뒤 외양을 재생성하면 파일이 저장되기 전에 체크박스가 다시 체크될 수 있었습니다
이 위에 뭘 세우기 전에 알아 둘 한계
스칼라 getter는 스칼라로 남습니다. GetFormFieldValue와 GetLoadedFormFieldDefaultValue는 배열 값에 대해 빈 문자열을 반환하고, 숫자와 불리언은 42나 true로 문자열화하며, hex 인코딩된 문자열은 16진수 표기 그대로 보고합니다. /Parent 사이클은 예외 없이 걷기를 끝내므로, 타입이 사이클 속에서 잃어버린 필드는 실패하는 대신 lfftUnknown과 플래그 0을 보고합니다. SetFormFieldValue와 ResetLoadedFormField는 항상 주소한 자식에 쓰고 값을 공유 부모로 승격하지 않는데, 독립 자식에게는 맞지만 라디오 그룹은 선택을 소유한 필드를 통해 주소해야 한다는 뜻입니다. 그리고 각 호출은 필드 하나를 자기 몫으로 커밋합니다. 여기 어떤 것도 리셋 배치를 트랜잭션으로 만들지 않습니다
여기서 설명한 상속 속성 해석, 통합 필드 트리 분류, 타입 리셋은 Delphi와 C++Builder용 HotPDF Delphi Component의 불러온 폼 API 일부이며, Delphi에서 불러온 PDF에 AcroForm 필드 추가하기에서 다룬 필드 생성과 함께 제공됩니다