기술 문서

Delphi AcroForm 상속 필드 값과 리셋

설문 양식이 Acrobat에서 드롭다운 리스트 둘을 보여줍니다. group.a와 group.b입니다. 자체 /FT를 달고 있는 노드를 완성된 필드로 취급하는 코드로 /AcroForm /Fields를 걸어 보면 카운트가 1로 돌아옵니다. 위젯 둘을 거느린 group이라는 단일 필드입니다. group.b를 이름으로 달라 하면 조회가 실패합니다. 자식 딕셔너리를 열어 보면 /FT도, /Ff도, 옵션 목록도 전혀 없습니다. 깨진 것이 아닙니다. 타입, 플래그, 옵션이 한 단계 위 부모에 살고 있고 PDF 명세는 그들이 거기 살기를 명시적으로 허용합니다

이 배치는 합법적이고 경제적입니다. 저작 도구는 /FT /Ch, /Ff 131078, /Opt 배열을 갖춘 그룹 노드를 한 번 쓰고, 그 아래 이름 붙은 자식 둘을 매다는데 각각은 /T, /Parent, /Rect, 자기 /V만 담은 필드+위젯 병합 딕셔너리입니다. 그런 양식을 올바르게 읽으려면 세 가지가 필요합니다. 상속 가능한 키마다 /Parent를 따라가기, 필드 트리가 실제로 끝나는 지점 판단하기, 리셋이 옳은 PDF 타입의 값을 쓰게 하기입니다. 이 글은 Delphi로 로드된 PDF에서 폼 필드 값 설정하기가 멈춘 곳에서 이어갑니다

필드는 부모에게서 어떤 AcroForm 항목을 물려받을 수 있는가

ISO 32000-1 §12.7.3.1(표 220)은 /FT, /Ff, /V, /DV를 상속 가능으로 표시하고 §12.7.4.3의 표 229는 텍스트 필드의 /MaxLen을 그렇게 표시합니다. 옵션 배열도 여기서는 같은 대우를 받습니다. choice 목록을 자식들 사이에 공유하는 생산 도구가 /Opt를 그룹에 저장하기 때문입니다. 지역 딕셔너리만 검사하는 리더는 완전히 정상인 자식에 대해 엉뚱한 타입, 0인 플래그, 빈 값을 보고하게 됩니다

HotPDF Delphi Component는 그런 읽기마다 같은 방식으로 답합니다. 키를 필드 자체 딕셔너리에서 찾고, 키가 있으면 간접 참조를 resolve하고 멈춥니다. 없으면 /Parent를 따라가 다시 시도하는데 최대 128단계입니다. 이 상한은 장식이 아닙니다. 손상된 파일의 /Parent 포인터는 /Kids와 상관없는 루프를 만들 수 있고, 그들을 무조건 믿는 리더는 그런 파일을 처음 만나는 순간 매달립니다

이 걷기에서 뒤집기 쉬운 규칙이 하나 있습니다. 키를 담은 첫 딕셔너리가 이기며, 거기 저장된 값이 빈 문자열이어도 마찬가지입니다. group.b의 지역 /V ()는 저자가 "이 자식은 비어 있다"고 말하는 것이고, 걷기는 부모가 가질지 모르는 값으로 올라가는 대신 거기서 멈춥니다. 빈 것을 "없음"으로 취급하면 지워진 자식이 부모의 선택을 표시하게 되는데, 저자가 쓴 것과 정반대입니다

HotPDF 상속 AcroForm 속성 다이어그램. 그룹 노드는 /FT, /Ff, /Opt를 한 번만 들고 이름 붙은 자식 group.a와 group.b는 /T, /Parent, /Rect, 지역 /V만 들며, 상속 속성 조회가 /Parent를 최대 128단계 걷고 키를 담은 첫 딕셔너리가 이기며 빈 지역 값이 부모를 가리는 모습
HotPDF는 /FT, /Ff, /V, /DV, /Opt를 하나의 부모-걷기 리졸버로 resolve하므로 이름 붙은 자식은 계속 주소 가능한 상태를 유지하고, 지역 빈 값은 위 그룹이 들고 있는 모든 것을 의도적으로 덮어씁니다

public 게터들이 모두 그 조회 위에 앉아 있습니다. GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue, 그리고 옵션 헬퍼 GetLoadedFormFieldOptionCount와 GetLoadedFormFieldOptions입니다. 그러므로 설문 그룹의 자식을 읽는 데 특별한 코드가 필요 없습니다:

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(비트 18) + NoExport(비트 3) + Required(비트 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;

GetFormField는 호출마다 새 THPDFLoadedFormField 래퍼를 돌려주고 이름을 모르면 예외를 일으킵니다. 이 호출을 래퍼의 try 블록 밖에 두고 래퍼는 직접 해제하세요

필드 트리가 끝나는 지점 찾기

말단 필드 판정에 지역 /FT는 엉터리 기준입니다. 부모가 타입을 공급하면서도 이름 붙은 자식 필드를 계속 소유할 수 있기 때문입니다. 끌리는 순회는 노드가 자체 /FT를 갖거나 /Kids가 없을 때마다 내려가기를 멈춥니다. 설문 양식에서 group은 /FT /Ch와 /Kids를 모두 갖고 있으므로 그 순회는 이를 위젯 주석 둘을 가진 필드 하나로 등록합니다. group.a와 group.b라는 이름은 증발하고 필드 카운트는 1입니다. 자식 이름 조회는 실패하고 값 쓰기는 공유 부모에만 닿을 수 있습니다

HotPDF는 대신 자식들을 봅니다. kid가 자체 /T를 갖거나, 자체 /Kids를 갖거나, /Subtype /Widget 딕셔너리가 아니면 자식 필드로 칩니다. 어떤 kid도 해당하지 않을 때만 노드가 말단이고 그때 kid들은 위젯으로 취급됩니다. 그 규칙 뒤의 두 엣지 케이스 모두 병합 딕셔너리에서 나오는데, §12.7.3.1은 필드가 위젯 정확히 하나를 가질 때 이를 허용합니다:

  • 이름 붙은 병합 딕셔너리는 /Subtype /Widget을 달고 있어도 여전히 자식 필드입니다. 서브타입 하나만으로 부모의 익명 위젯 목록으로 밀어 넣을 수 없고 /T가 결정합니다
  • 어떤 생산 도구는 부모의 /FT를 모든 익명 위젯에 되풀이해서 적습니다. 그러므로 위젯의 /FT도 "여기서 새 필드가 시작된다"로 읽어서는 안 됩니다

같은 분류가 필드 인덱스, FormFieldExists, RemoveFormField를 움직입니다. 그 걷기들도 이미 방문한 딕셔너리를 기록하고 128단계를 넘으면 포기합니다. 그룹이 객체 5이고 자기 kid 목록에 자기 자신을 두 번 적는 경우, 곧 /Kids [5 0 R 5 0 R 6 0 R 7 0 R]를 상상해 보세요. 그래도 정확히 두 필드, 자식 6과 7을 보고합니다. 무한 재귀도 없고 자기 참조에서 생긴 유령 세 번째 필드도 없습니다

RemoveFormField는 어떻게 형제 필드를 지우지 않는가

RemoveFormField는 여러분이 이름 지은 자식만 지웁니다. 발견과 삭제가 말단 필드에 대한 같은 정의를 공유하기 때문입니다. 이름 오버로드는 필드 목록에서 필드 인덱스를 resolve한 다음 지울 딕셔너리를 찾으려고 /AcroForm /Fields를 두 번째로 걷으며 말단 필드를 셉니다. 두 번째 걷기가 끌리는 /FT 규칙을 쓰고 첫 걷기가 kid 기반 규칙을 쓰면 인덱스가 어긋납니다. 삭제 걷기는 group을 필드 0으로 보고, group.a 제거가 부모와 형제와 위젯 둘을 함께 날립니다

그래서 삭제 걷기는 발견과 같은 자식 필드 검사와 같은 방문 집합을 적용합니다. 제거된 자식에 속한 위젯 주석들을 모아 각 페이지의 /Annots에서 그것들만 떼어 내고, 부모는 /Kids 배열이 비게 될 때만 제거합니다. group.b가 아직 나열돼 있으면 부모는 남고, group.b가 물려받는 타입·플래그·옵션도 함께 남습니다

HotPDF RemoveFormField 형제 생존 다이어그램. 삭제 걷기는 발견과 같은 자식 필드 검사와 방문 집합을 쓰고, AcroForm /Fields와 페이지 /Annots에서 이름 붙은 자식 group.a만 떼어 내며, /Kids 배열이 살아남은 group.b를 계속 품는 동안 공유 부모를 유지한다
발견과 삭제는 말단 필드가 무엇인지에 대해 합의하므로, 이름 붙은 자식 하나를 지워도 전체 재작성이나 증분 업데이트 후에 형제의 값과 appearance는 온전합니다
// 이름 붙은 자식 하나를 제거한다. 형제와 공유 부모는 살아남는다
Pdf.RemoveFormField('group.a');

// 직접 추가할 점검들. 라이브러리의 일부가 아니다
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// 타입, 플래그, 옵션은 여전히 부모를 통해 resolve된다
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);

Pdf.SaveLoadedDocument('survey-trimmed.pdf');

자기 양식에서 제거를 확인할 때는 세 곳을 보세요. 각각 다른 실수를 잡습니다. 부모의 /Kids, 페이지 /Annots, 그리고 살아남은 형제의 값과 appearance입니다. SaveLoadedDocument 후 한 번, SaveIncrementalUpdate 후 한 번 하세요. 증분 저장은 바뀐 객체만 append하므로 부모의 /Kids가 그 안에 있어야 합니다

ResetLoadedFormField가 상속 /DV에서 복사하는 것

ResetLoadedFormField는 상속된 /DV의 새 사본인 지역 /V를, 같은 PDF 객체 타입으로 기록하고, 필드를 만지기 전에 기본값 전체를 검증합니다. 체크박스 기본값은 /Yes 같은 name입니다. 멀티셀렉트 리스트 박스 기본값은 문자열 배열입니다. 텍스트 기본값은 <FEFF...> 같은 16진수 UTF-16 문자열일 수 있습니다. 그중 하나라도 GetLoadedFormFieldDefaultValue와 SetFormFieldValue로 왕복시키면 name이 있어야 할 자리에 문자열이 오고, 배열이 있던 자리에 빈 문자열이 오고, 생 16진수 숫자가 나옵니다

그래서 리셋은 상속된 /DV의 실체에 따라 갈라집니다:

  • 문자열 기본값을 가진 텍스트·choice 필드는 원본의 16진수 플래그를 유지하는 새 문자열 객체를 받는다
  • 배열 기본값을 가진 choice 필드는 새 문자열들의 새 배열을 받는다. 문자열 외의 것을 담은 배열은 예외를 일으킨다
  • 체크박스와 라디오 버튼은 새 name 객체를 받는다
  • 그 외 타입의 기본값은 예외를 일으키고 /V와 /I는 원래 그대로 남는다
HotPDF 타입 인지 리셋 다이어그램. ResetLoadedFormField는 상속된 /DV 객체 타입으로 갈라져 체크박스에는 새 name 객체를, 멀티셀렉트 choice에는 새 문자열들의 새 배열을, hex 텍스트에는 IsHexadecimal을 유지하는 문자열을, /DV가 없으면 빈 문자열이나 /Off를 쓰고 타입 불일치에서는 /V와 /I를 건드리지 않은 채 예외를 일으킨다
부모의 객체를 가리키는 대신 복사하면 나중의 값 편집이 조용히 기본값을 바꾸는 일이 없어지고, pushbutton과 signature 필드는 평범한 문자열 경로를 갑니다

참조 대신 복사하는 것은 의도된 설계이자 유일하게 안전한 선택입니다. 부모의 /DV 배열을 가리키는 자식 /V는 "현재 값"과 "기본값"이 하나의 객체를 공유하게 만들고, 그 자식의 다음 SetFormFieldValue가 모든 형제의 기본값을 조용히 다시 씁니다. 리셋 필드마다 몇 바이트 추가되는 것은 둘을 가르는 값싼 대가입니다. 값을 갖지 않는 pushbutton(표 226, 비트 17)과 signature 필드는 자체 /DV의 문자열 사본을 쓰는 평범한 경로에 머뭅니다

사슬 어디에도 /DV가 없으면 메서드는 지역 빈 문자열을, 체크박스나 라디오 필드에는 /Off를 씁니다. 자식의 /V를 지우는 편이 더 깔끔해 보이지만 엉터리 답을 줍니다. 부모가 현재 값을 들고 있으면 자식의 재정의를 지우는 순간 그 값이 그대로 돌아오고, "리셋된" 필드가 사용자가 한 번도 입력한 적 없는 것을 표시합니다

var
  Field: THPDFLoadedFormField;
begin
  // 부모 'palette'는 /Opt [(b) (g) (r)]과 /DV [(b) (r)]을 가진
  // MultiSelect 리스트 박스고 자식은 /T와 /V만 들고 있다
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // 자식은 이제 자체 /V [(b) (r)]과 /I [0 2]를 갖고
      // 부모의 /DV 배열은 그대로다
    except
      on E: Exception do
        // 잘못된 기본값(엉터리 타입, 문자열 아닌 배열 항목)이면:
        // /V와 /I가 원래 그대로 남았다
        Writeln('Reset skipped: ', E.Message);
    end;
    // 스칼라 게터는 배열 기본값을 표현하지 못한다
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // 빈 값
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

단일 필드 리셋은 §12.7.5.3의 ResetForm 액션과 다른 것입니다. 그 액션은 사용자가 버튼을 누를 때 뷰어가 필드 집합 위에서 돌리는 것이고, HotPDF로 AcroForm 필드와 액션 만들기에서 다룹니다. ResetLoadedFormField는 로드된 필드 하나를 편집하고, 기본값 없는 사례에 대한 자체 규칙을 따르며, 필드를 변경됨으로 표시해 증분 재계산이 집어 들게 합니다

/V, /I, /AS의 정합성 유지

선택 인덱스와 appearance 상태가 값을 따라올 때만 리셋이 옳고, ResetLoadedFormField는 SetFormFieldValue와 같은 뒤처리 두 단계로 막습니다. choice 필드에서는 자식의 지역 /I를 제거하고 각 값을 모든 /Opt 항목의 export 절반과 대조하며 정렬된 새 /I를 씁니다. 옵션 b, g, r에 대해 [(b) (r)]로 리셋하면 /I [0 2]가 나옵니다. 부모가 물려준 /I가 있다면 그것은 그대로입니다

버튼은 상속된 타입으로 동작합니다. /FT /Btn이 부모에 사는 자식 체크박스도 /AS 갱신을 받습니다. 쓰기 쪽에서는 SetFormFieldValue와 SetLoadedFormFieldDefaultValue가 상속된 non-pushbutton 버튼에 대해 자식에게 타입을 베낄 지역 항목이 없어도 name 객체를 저장합니다. 그리고 EnsureLoadedFieldAppearanceStream이 버튼 appearance를 재구축할 때는 값이 on 상태와 일치하지 않으면 /AS /Off를 쓰고 각 상태 스트림에 /Type /XObject, /Subtype /Form, /BBox를 부여합니다. 그 /Off 검사가 없으면 리셋 뒤의 appearance 재생성이 파일이 저장되기도 전에 체크박스를 다시 체크한 모습으로 보여 줍니다

스칼라 게터와 나머지 엣지들

문자열 게터들은 스칼라인 채로 머물고, 그 출력을 비교하는 코드는 무엇이 평탄화되는지 알아야 합니다. GetFormFieldValue와 GetLoadedFormFieldDefaultValue는 배열 값에 대해 빈 문자열을 돌려주고 숫자와 불리언을 42나 true로 바꾸며, hex 인코딩 문자열은 16진수 표기로 보고합니다. 리스트 박스 기본값이 선택 둘인지 0인지 알아야 한다면 기본값 게터는 말해 줄 수 없습니다. 둘 다 빈 문자열로 돌아옵니다

/Parent 순환은 예외를 일으키지 않고 조회를 끝내므로, 타입이 순환 속에 잃어버린 필드는 lfftUnknown과 플래그 0을 보고합니다. 이름 붙은 필드의 lfftUnknown은 기록할 가치가 있는 손상 파일의 징조로 취급하세요. SetFormFieldValue와 ResetLoadedFormField는 언제나 주소 지은 자식에 기록하고 값을 공유 부모로 승격시키지 않습니다. 두 설문 드롭다운 같은 독립 자식에게는 그것이 옳습니다. 라디오 그룹은 다릅니다. 선택은 그룹을 소유한 필드의 것이므로 kid 하나가 아니라 그 필드를 향하세요

각 호출은 필드 하나를 자기 몫으로 확정합니다. 20개 필드의 리셋 루프는 20개의 독립 편집이고 다섯 번째가 잘못된 기본값으로 예외를 일으키면 앞의 넷은 이미 적용돼 있습니다. 배치 작업이라면 모든 대상의 타입과 기본값을 먼저 읽고, 만질 필드를 정한 다음에야 쓰기를 시작하세요

상속 속성 조회, kid 기반 필드 트리 분류, 타입 인지 리셋은 모두 HotPDF Delphi Component의 loaded-form API에 속하며, 이 API는 기존 문서에 새 필드를 추가해야 하는 Delphi·C++Builder 프로젝트도 커버합니다. Delphi에서 로드된 PDF에 AcroForm 필드 추가하기에서 보이는 것처럼요