기술 문서

Delphi로 로드된 PDF의 폼 필드 값 설정하기

HotPDF Delphi Component는 로드된 PDF의 기존 AcroForm 필드를 THotPDF.SetFormFieldValue로 채우며, 0 기반 필드 인덱스나 완전한 정규화 필드 이름으로 지정합니다. 새 /V 항목을 쓰는 것은 쉬운 부분이고, 이 호출을 실제 세상의 폼에서도 믿을 만하게 만드는 것은 같은 메서드가 잘못되기 전까지는 보이지 않는 세 가지 상태도 일관되게 유지한다는 점입니다. non-ASCII 이름도 찾을 수 있게 해 주는 필드의 디코딩된 아이덴티티, 체크박스와 라디오 위젯의 /AS appearance 상태, 그리고 선택 필드의 /I 선택 인덱스 배열입니다. 눈에 보이는 appearance 스트림은 EnsureLoadedFieldAppearanceStream을 통한 별개의 명시적 단계입니다

시나리오는 지극히 평범합니다. 고객이 자기네 폼, 세금 신고서, 보험 청구서, 몇 년 전에 누군가 Acrobat에서 만든 발주서를 보내오고, 여러분의 Delphi 애플리케이션이 그것을 데이터베이스에서 채워 어디서나 제대로 열리는 파일로 돌려줘야 합니다. 폼이 어떻게 작성되었는지는 여러분이 통제할 수 없습니다. 필드 이름이 UTF-16으로 인코딩되어 있을 수 있고, 체크박스 내보내기 값이 Yes가 아니라 2일 수 있으며, 콤보 박스가 [export display] 옵션 쌍을 쓸 수도 있습니다. 이런 세부 사항마다 ISO 32000-1에 규칙이 있고, 각각의 규칙을 이제 SetFormFieldValue가 대신 처리해 줍니다. 이 글은 그것이 무엇을 하고, 왜 그렇게 하며, 어디에서 멈추는지를 다룹니다. 아직 없는 필드를 만드는 짝 문제는 Delphi에서 로드된 PDF에 AcroForm 필드 추가하기를 보십시오

SetFormFieldValue는 non-ASCII 이름의 필드를 왜 찾지 못할까요?

v2.752.1 이전의 답은 인코딩이었습니다. 필드가 파일 안에서 16진수 UTF-16BE 이름으로 살고 있었고, 이름 캐시가 텍스트 대신 그 hex 표기를 저장하고 있었습니다. ISO 32000-1 §12.7.3.1은 부분 필드 이름 /T를 텍스트 문자열로 정의하고, §7.9.2.2는 텍스트 문자열이 앞에 FE FF 바이트 순서 표시를 둔 UTF-16BE일 수 있다고 합니다. 저작 도구들은 흔히 그런 이름을 §7.3.4.3에 따라 hex 문자열로 직렬화하므로, Straße라는 필드는 <FEFF005300740072006100DF0065>로 도착합니다. HotPDF 내부에서 THPDFStringObject.Value는 IsHexadecimal이 설정되어 있으면 원시 16진수 텍스트를 담는데, 이는 원본 딕셔너리를 손실 없이 왕복시키는 데는 정확히 원하는 바이고 조회 키로는 정확히 원하지 않는 바입니다. HPDFLoadedFormTextName이 두 관심사를 분리합니다. 관계 캐시를 만들 때 모든 /T 값이 이 함수를 거칩니다. 문자열 객체가 16진수이면 HPDFHexToBytes가 바이트 열을 복원하고, 바이트가 FE FF로 시작하고 길이가 짝수이면 페이로드를 UTF-16BE로 디코딩해 UTF-8로 다시 인코딩합니다. 그 결과는 부모 이름과 마침표로 이어 붙여 §12.7.3.1이 기술하는 완전한 정규화 이름이 되므로, Address라는 부모 아래의 City는 Address.City로 등록됩니다. 캐시 키는 소문자로 정규화되어 SetFormFieldValue('address.city', ...)도 성공합니다. 규격이 이름을 대소문자 구분으로 취급하므로, 이는 표준을 넘어선 편의입니다. 결정적으로 바뀌는 것은 캐시 키뿐입니다. 필드 딕셔너리의 /T 객체는 16진수 인코딩을 그대로 유지하므로, 문서를 저장해도 단지 채우기만 한 필드의 아이덴티티를 다시 쓰지 않습니다

HotPDF가 non-ASCII AcroForm 이름을 해석하는 방식: HPDFHexToBytes가 16진수 /T 문자열 뒤의 UTF-16BE 페이로드를 복원하고, FE FF 바이트 순서 표시가 디코딩되어 UTF-8로 다시 인코딩되며, 정규화 이름이 부모와 이어져 Applicant.FullName과 Straße라는 필드가 모두 조회 캐시에 들어갑니다
바뀌는 것은 캐시 키뿐입니다. 필드 딕셔너리는 16진수 인코딩을 유지하고, 조회는 표준을 넘어선 편의로 소문자로 정규화되며, 문서를 저장해도 단지 채우기만 한 필드의 아이덴티티를 다시 쓰지 않습니다
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // 정규화 이름은 UTF-16BE /T 문자열에서 디코딩되어 마침표로
    // 이어지므로, 중첩된 이름과 non-ASCII 이름이 모두 해석됩니다
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Latin-1이 아닌 값은 FEFF가 앞에 붙은 UTF-16BE hex로 다니며
    // PDF 16진수 문자열로 기록됩니다
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

SetFormFieldValue는 실제로 무엇을 쓸까요?

두 오버로드 모두 같은 다섯 단계를 수행합니다. 필드 딕셔너리를 찾고, HPDFSetDictFormValue로 /V를 쓰고, 선택 인덱스를 조정하고, 딕셔너리를 dirty로 표시하고, 버튼 appearance 상태를 조정하고, 마지막으로 NoteLoadedFormFieldDirty로 필드 인덱스를 기록합니다. 마지막 단계는 폼에 계산 스크립트가 있을 때 중요합니다. 인자가 없는 RecalculateLoadedFormFieldsIncremental 오버로드가 소비하는 dirty 집합으로, 변경된 필드를 전이적으로 읽는 계산만 다시 실행하기 때문입니다. HPDFSetDictFormValue 자체는 자기가 대체하는 객체 타입에 주의합니다. 기존 /V가 이름 객체, 즉 체크박스와 라디오 필드가 내보내기 값으로 쓰는 형태이면 새 값도 이름으로 기록하고 절대 문자열로 쓰지 않습니다. PDF 이름은 구조상 ASCII 전용이기 때문입니다. 그렇지 않으면 문자열 객체를 쓰고 넘어온 값을 검사합니다. FEFF로 시작하고 길이가 짝수이며 16진수 숫자로만 이루어진 문자열은 §7.9.2.2의 UTF-16BE 전송 형식으로 취급되어 IsHexadecimal이 설정된 채 저장되므로, 리터럴 (FEFF...)가 아니라 <FEFF...>로 직렬화됩니다. 위의 City 줄이 기대는 메커니즘이 이것입니다. 다른 문자열은 넘긴 바이트 그대로 리터럴 문자열로 저장되므로, 평범한 라틴 텍스트에는 평범한 텍스트를 넘기면 됩니다

체크박스는 값을 바꾼 뒤에도 왜 옛 체크 표시를 유지할까요?

버튼 필드에서는 값만으로 무엇이 그려질지 정해지지 않기 때문입니다. ISO 32000-1 §12.7.4.2.3은 체크박스 위젯이 /AP /N 안의 어느 스트림이 현재 표시되는지를 지정하는 /AS appearance 상태를 달고 있다고 규정하고, 뷰어는 /V가 아니라 /AS에서 그립니다. /V를 Yes로 바꾸면서 /AS를 Off로 남겨 두면 파일 내부가 모순되고, 플래튼은 기쁘게도 낡은 미체크 appearance를 페이지에 구워 넣는 동안 폼 데이터는 체크됨이라고 말합니다. ReconcileLoadedButtonAppearanceStates가 그 간극을 메우기 위해 존재합니다. /FT가 Btn인 필드에 대해 필드 딕셔너리 자체와 그 /Kids 배열의 모든 항목을 방문하고, /AP /N에서 on 상태 이름을 읽고, 필드 값과 일치하면 /AS를 그 이름으로, 일치하지 않으면 Off로 다시 씁니다

HotPDF 체크박스가 /V만 바뀌었을 때 옛 체크 표시를 유지하는 이유: 뷰어는 /AP /N 안의 /AS appearance 상태에서 그리므로, ReconcileLoadedButtonAppearanceStates가 필드와 모든 자식을 방문해 Off가 아닌 첫 키를 on 상태 이름으로 읽고, 일치하면 /AS를 그 값으로, 아니면 Off로 다시 씁니다
라디오 그룹은 InheritedButtonValue가 /Parent 사슬을 거슬러 복구한 부모 값과 각 자식을 비교하므로, 그룹을 하나의 내보내기 값으로 설정하면 정확히 그 위젯만 켜지고 형제들은 모두 꺼집니다

실제 폼에서 나온 세부 사항 두 가지가 v2.752.3 수정을 빚었습니다. 첫째, normal appearance 딕셔너리는 on 상태만 담아도 됩니다. §12.7.4.2.3은 off appearance의 이름을 Off라고 하지만 저작 도구들은 그 스트림을 자주 생략하고 뷰어가 아무것도 그리지 않게 둡니다. 예전 코드는 딕셔너리 항목이 두 개 미만이면 그냥 빠져나갔기 때문에, 그런 단일 상태 체크박스는 조용히 옛 체크 표시를 유지했습니다. 이제 검사는 딕셔너리가 비어 있지 않은지 확인하는 것뿐이고, on 상태 이름은 Off가 아닌 첫 키로 잡습니다. 둘째, on 상태 이름은 저자가 정한 대로입니다. 실제 폼은 2, Yes, On 또는 현지어 단어를 쓰므로, 비교는 하드코딩된 Yes가 아니라 실제 키와 대소문자 구분 없이 이루어집니다. 라디오 버튼에는 §12.7.4.2.4에 나오는 주름이 하나 더 있습니다. 선택은 부모 필드의 /V에 있고, 개별 자식들은 위젯을 소유하지만 대개 자기 /V를 갖지 않습니다. 그래서 중첩된 InheritedButtonValue 헬퍼가 /Parent 사슬을 최대 64단계까지 거슬러 올라가 비어 있지 않은 값을 찾고, 각 자식은 자기가 속한 그룹의 값과 비교됩니다. 부모를 한 자식의 내보내기 값으로 설정하면 정확히 그 자식만 켜지고 형제들은 모두 꺼집니다

// 체크박스: 내보내기 값이 /AP /N의 on 상태 키와 일치해야 합니다
// (흔히 'Yes'지만, 실제 폼은 '2', 'On' 등 무엇이든 씁니다)
Pdf.SetFormFieldValue('Consent', 'Yes');

// 라디오 그룹: /V는 부모에 기록되고, 모든 자식 위젯은
// 자기 내보내기 이름이나 Off로 /AS가 설정됩니다
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// 체크박스 해제: 어떤 on 상태와도 맞지 않는 값은 /AS를 Off로 만듭니다
Pdf.SetFormFieldValue('Newsletter', 'Off');

선택 필드: /I를 /V와 맞춰 유지하기

콤보 박스나 리스트 박스에서는 /V가 선택을 기록하는 유일한 자리가 아닙니다. §12.7.4.4의 표 231은 /I를 선택된 항목을 식별하는, /Opt에 대한 0 기반 인덱스 배열로 정의하며, /I는 옵션 0을 가리키는데 /V는 옵션 3을 말하는 뷰어는 엉뚱한 행을 강조할 수 있습니다. v2.754.1부터 HPDFReconcileChoiceSelection이 모든 SetFormFieldValue 호출 안에서 돌고, 상속된 /FT가 Ch일 때 새 값으로 /I를 다시 만듭니다. 작업 순서는 의도적입니다. 로컬 /I 항목을 내용은 건드리지 않고 먼저 삭제합니다. 옛 배열이 다른 필드와 공유되는 간접 객체였다면 제자리에서 변경하는 것이 다른 필드의 선택을 망가뜨리므로, 루틴은 그 참조를 버리고 새 직접 배열을 만듭니다. 그다음 선택 옵션이 상속될 수 있으므로 /Parent 사슬을 통해 /Opt를 해석하고 항목들을 훑습니다. 맨 문자열 옵션은 직접 비교하고, [export display] 쌍은 export 원소로 비교하며, 원소가 둘 미만인 쌍은 건너뜁니다. 양쪽이 HPDFLoadedFormTextName을 거치므로, hex UTF-16 옵션이 hex UTF-16 값과 같은 철자를 쓰지 않아도 일치합니다. 첫 일치에서 원소 하나짜리 /I가 기록되고 스캔은 멈춥니다. 스칼라 값은 MultiSelect 플래그와 무관하게 언제나 이전의 다중 선택을 대체합니다

HotPDF가 선택 필드를 일관되게 유지하는 방식: HPDFReconcileChoiceSelection이 로컬 /I 배열을 건드리기 전에 삭제하고, /Parent 사슬을 통해 /Opt를 해석하며, 각 옵션의 export 절반을 HPDFLoadedFormTextName으로 비교하고, 첫 일치에서 원소 하나짜리 /I를 쓰며, 편집 가능한 콤보 값에 인덱스가 없으면 아무것도 쓰지 않습니다
맨 문자열 옵션은 직접 비교하고 export display 쌍은 export 원소로 비교하며, /Opt 밖의 값은 올바르게도 인덱스를 남기지 않습니다 — 엉뚱한 행을 가리키는 낡은 /I는 없는 것보다 나쁩니다

아무것도 일치하지 않으면 /I를 아예 쓰지 않습니다. 편집 가능한 콤보 박스에서는 이것이 올바른 결과입니다. §12.7.4.4가 사용자가 옵션 목록 밖의 값을 입력하는 것을 허용하고, 그런 값에는 인덱스가 없으며, 낡은 인덱스는 없는 것보다 나쁩니다. 쌍을 이루는 옵션 목록에 내보내기 값 대신 표시 라벨을 넘겼을 때도 같은 결과가 나오므로, 콤보 박스가 여러분의 선택을 보여 주지 않는다면 쌍의 어느 절반을 넘겼는지 확인하십시오

// /Opt가 [[US United States] [CA Canada] [MX Mexico]]일 때:
// export 값으로 일치하고 /I는 [1]이 됩니다
Pdf.SetFormFieldValue('Country', 'CA');

// 편집 가능한 콤보에 /Opt 밖의 값을 주면: /V는 기록되고
// /I는 제거되며 인덱스를 지어내지 않습니다
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

값과 appearance는 별개의 두 작업입니다

SetFormFieldValue는 텍스트나 선택 필드의 appearance 스트림을 결코 건드리지 않습니다. 호출 후 /V는 새 텍스트를 담고 있는데 /AP /N은 여전히 옛 텍스트를 그리며, 뷰어가 둘 중 무엇을 보여 줄지는 AcroForm 딕셔너리가 §12.7.3.3에 따라 /NeedAppearances true를 달고 있느냐와 뷰어가 그것을 존중하느냐에 달려 있습니다. 플래그를 무시하는 플래튼과 썸네일 생성기를 포함해 모든 리더가 새 값을 렌더링하게 하려면 필드 인덱스와 함께 EnsureLoadedFieldAppearanceStream를 호출하십시오. 이 함수는 상속된 /DA 문자열, /Q 정렬, /MaxLen comb 레이아웃과 값으로 Form XObject를 만들고, AcroForm /DR 리소스를 통해 이름 있는 폰트를 해석해서 Type0 폰트가 Helvetica로 격하되지 않고 자기 descendant font를 유지하게 하며, 위젯 하나 이상이 스트림을 받으면 True를 반환합니다. SetFormFieldValue의 이름 기반 오버로드는 인덱스를 돌려주지 않으므로, GetFormField로 하나 받아 오십시오. 반환되는 THPDFLoadedFormField는 여러분 소유이고 직접 해제해야 합니다. v2.752.1 변경의 회귀 스위트는 이 분리를 분명히 합니다. 값을 설정하고, EnsureLoadedFieldAppearanceStream를 호출하고, 페이지를 렌더링한 다음, 위젯 사각형 안의 픽셀은 바뀌었고 밖의 픽셀은 바뀌지 않았는지 확인합니다. /V가 바뀌었다는 검증은 사용자가 무엇을 보게 될지에 대해 아무것도 증명하지 않습니다

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // /NeedAppearances를 무시하는 뷰어도 보여 줄 수 있도록
    // 새 값을 /AP에 그려 넣습니다
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

이 위에 무언가를 만들기 전에 알아 둘 한계

ReconcileLoadedButtonAppearanceStates는 지정한 딕셔너리의 로컬 /FT를 검사하므로, 라디오 부모나 자기 /FT를 들고 있는 체크박스에 작용합니다. /FT가 부모에만 있는 자식 위젯을 따로 지정하면 그 경로로는 조정되지 않습니다. HPDFReconcileChoiceSelection은 스칼라 값 하나를 다루고 인덱스를 최대 하나만 씁니다. 여러 항목을 고른 다중 선택 리스트 박스는 SetFormFieldValue가 모델링하는 범위 밖입니다. 두 루틴 모두 넘긴 값을 /Opt나 on 상태 키와 검증하지 않으므로, 오타는 예외가 아니라 Off 체크박스나 인덱스 없는 콤보를 만들어 냅니다. 그리고 GetFormFieldValue는 딕셔너리에 들어 있는 그대로의 /V 텍스트를 반환하는데, hex로 인코딩된 값이라면 디코딩된 텍스트가 아니라 16진수 표기입니다

값이 들어가고 appearance가 그려진 다음의 자연스러운 두 걸음은 이 작업의 양옆에 있습니다. SetFormFieldValue를 한 번씩 호출하는 대신 외부 시스템과 필드 데이터를 대량으로 주고받는 일은 Delphi에서의 XFDF 가져오기와 내보내기가 다룹니다. 그리고 채워진 폼이 최종이라 더 이상 편집할 수 없어야 할 때는 Delphi에서 AcroForm과 XFA 필드 플래튼하기가 여기서 설명한 /AS 상태와 appearance 스트림을 그대로 정적 페이지 콘텐츠로 구워 넣습니다. 그래서 플래튼 전에 그것들을 일관되게 만드는 일이 선택 사항이 아닙니다

이 글에서 다룬 로드된 폼 편집 API, 즉 SetFormFieldValue, EnsureLoadedFieldAppearanceStream, 증분 재계산 그래프는 Delphi와 C++Builder용 HotPDF Delphi Component의 일부로 제공됩니다