PDFium Component는 v3.125.2 이상과 함께 배송되는 Windows V8 런타임 pdfium.v8.dll을 돌릴 때 편집된 XFA 폼 값을 저장과 다시 열기를 거쳐 정확히 보존합니다. 더 오래된 런타임은 필드 값에 줄바꿈을 얹고, 이모지를 무관한 BMP 문자로 잘라 버리고, 단일 스트림 XFA 저장을 조용히 건너뛰고, 실패한 마지막 쓰기를 삼킬 수 있었습니다. 한 가지 재오픈 증상은 라이브러리 결함이 전혀 아닙니다. 루트 서브폼에 restoreState="auto"가 없는 다이내믹 폼은 레이아웃을 템플릿에서 다시 빌드합니다
이것의 버그 리포트는 모두 비슷하게 생겼습니다. 고객이 Delphi 뷰어에서 XFA 청구 폼을 채우고, 저장하고, 다시 열면 뭔가 살짝 어긋나 있습니다. 비어 있던 의견 상자가 이제 빈 줄 하나를 담고 있고, 두 번째 저장 뒤에는 둘을 담습니다. 이모지를 곁들여 입력한 이름이 사유 영역 글리프로 돌아옵니다. 아무도 오류를 받지 않으며, 그것이 이 버그들을 비싸게 만듭니다. 어긋남은 몇 주 뒤 다른 사람의 내보내기에서 표면화되니까요
XFA 폼을 저장하고 다시 열면 무엇이 잘못될까?
네이티브 XFA 저장 경로의 별개 결함 넷이 값 어긋남을 일으켰고, 각각은 성공처럼 보이는 저장 뒤에 숨었습니다. 둘은 직렬화에서, 하나는 단일 스트림 저장 레이아웃에서, 하나는 PDF writer 자체에서 왔습니다. 표는 각 증상을 원인과 PDFium Component가 고친 릴리스에 사상합니다
| 재오픈 뒤 증상 | 원인 | 수정 버전 |
|---|---|---|
| 빈 필드가 줄바꿈을 담음, 저장마다 값이 줄바꿈 하나씩 자람 | 두 XFA writer 모두 시작 태그 뒤에 레이아웃 줄바꿈을 삽입 | v3.125.2, pdfium.v8.dll |
| U+1F642가 U+F642로 돌아오거나 이모지가 폼 패킷에서 사라짐 | 디코딩에서 16비트 wchar_t 절단, 폼 직렬화기의 서러게이트 필터링 | v3.125.2, pdfium.v8.dll |
| 단일 스트림 XFA 문서의 편집이 그저 사라짐 | 네이티브 저장이 스트림 레이아웃을 거부했는데 반환값이 무시됨 | v3.125.2, 주석과 처리 명령은 v3.126.0부터 보존 |
| 저장이 성공을 보고했는데도 잘린 파일 | writer가 이미 성공을 돌려준 뒤 마지막 버퍼링된 쓰기가 실패 | v3.125.2 V8 런타임, v3.125.3 평범한 pdfium.dll |
| 세 페이지 다이내믹 폼이 두 페이지로 재오픈됨 | 루트 서브폼이 restoreState="auto"를 요청하지 않음 | 폼 저작 문제, 라이브러리 결함 아님 |
이전 글들은 XFA 필드 편집을 PDFium으로는 아예 영속할 수 없다고 결론 내렸는데, 당시 런타임으로는 정확한 진단이었습니다. 더 새로운 V8 런타임은 XFA 값을 네이티브로 저장하므로, 살아 있는 폼에서 가한 편집이 여러분이 패킷 수술을 하지 않고도 저장된 datasets 패킷에 도달합니다
어떤 PDFium 런타임이 XFA 값을 저장하나?
XFA 저장 충실도는 Delphi 래퍼가 아니라 네이티브 DLL에 달려 있으므로, 첫 검사는 프로세스가 실제로 어떤 런타임을 로드했는지입니다. PDFium Component는 아키텍처마다 Windows 빌드 둘을 배송합니다. V8과 XFA 없이 빌드된 평범한 pdfium.dll과, JavaScript 엔진과 XFA 폼 런타임을 실은 pdfium.v8.dll이요. XFA 폼을 돌릴 수 있는 것은 pdfium.v8.dll뿐이므로, 여기서 기술하는 모든 XFA 수정은 그곳에 삽니다. v3.125.2의 재빌드된 Win32와 Win64 V8 라이브러리부터요
마지막 쓰기 수정은 범용 PDF writer 코드이므로, 평범한 문서에도 중요합니다. v3.125.3은 같은 수리를 실도록 평범한 pdfium.dll 라이브러리를 재빌드했습니다. 소스 공유는 동작 공유의 증거가 아닙니다. 바이너리가 재빌드되기 전까지 옛 DLL은 옛 버그를 간직합니다
두 번째 함정은 로더에 앉아 있었습니다. v3.125.2 전에는 EnableV8Engine을 True로 설정하면 바인딩이 기본 pdfium.v8.dll 이름을 고르고 LibraryName의 전체 경로를 무시했습니다. 갓 배포한 런타임을 가리킨 애플리케이션이 다른 폴더의 더 오래된 사본을 계속 로드할 수 있었죠. v3.125.2부터 디렉터리를 포함하는 LibraryName은 어느 엔진 모드에서든 정확히 그 파일을 고르고, 없는 경로는 다른 번들 라이브러리로 폴백하는 대신 실패합니다
uses
System.SysUtils, PDFium;
procedure SelectXfaRuntime;
begin
// LibraryName의 디렉터리는 정확히 이 파일을 고정함(v3.125.2 이상),
// 파일이 없으면 폴백하는 대신 로드가 예외를 일으킴
{$IFDEF WIN64}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
PDFium.EnableV8Engine := True;
PDFium.LoadLibrary; // 첫 저장 때가 아니라 시작 때 실패
end;
문서를 연 뒤 TPdf.XFA는 파일이 XFA를 담고 있다고 알려 주고, TPdf.XfaRuntimeAvailable은 로드된 DLL이 실제로 그것을 실행할 수 있다고 알려 줍니다. 정적 폼과 다이내믹 폼도 가르야 한다면 TPdf.FormType은 ftXfaFull이나 ftXfaForeground를 돌려줍니다. 그 탐침의 세부는 Delphi에서 XFA 폼 탐지와 XFA 패킷 추출하기 글이 자세히 다룹니다
저장된 XFA 필드는 왜 줄바꿈을 얻을까?
저장된 XFA 필드가 줄바꿈을 얻은 이유는 두 네이티브 XFA writer, 범용 XML 요소 writer와 폼 패킷 직렬화기가 시작 태그 뒤에 줄바꿈을 넣어 출력을 예쁘게 인쇄했기 때문입니다. 대부분의 XML에서 그 공백은 미용입니다. XFA 데이터에서는 아닙니다. datasets 패킷이 다시 파싱될 때 <Comments>와 </Comments> 사이의 텍스트가 줄바꿈을 포함해 필드 값이기 때문입니다. 그래서 빈 필드는 LF 하나를 담은 채 재오픈됐고, 이후의 저장-재오픈 사이클마다 하나씩 더 얹힐 수 있었습니다
눈에 띄는 수리인 로드 때 값 다듬기는 틀린 방법입니다. 사용자는 XFA 필드에 선행 공백, 후행 공백, 의도적인 여러 줄 텍스트를 입력하고, 주소 블록이나 고정 폭 코드는 바이트 그대로 살아남아야 합니다. 그래서 v3.125.2 수정은 직렬화기 자신이 태그 주위에 합성한 공백만 제거합니다. 사용자 값, 기존 텍스트 노드, CDATA 섹션은 손대지 않고 통과하므로, " indented"는 들여쓴 채로 남고 의도적으로 비운 필드는 비어 있는 채로 남습니다
이모지는 왜 다른 문자로 돌아올까?
이모지가 잘못 돌아온 이유는 Windows wchar_t가 16비트 폭인데 두 디코딩 경로가 온전한 유니코드 스칼라 값을 단일 wchar_t에 저장했기 때문입니다. UTF-8 스트림 디코더와 🙂 같은 숫자 문자 참조 파서가 둘 다 그렇게 했습니다. 살짝 웃는 얼굴인 U+1F642는 16비트에 들어가지 않으므로 높은 비트들이 떨어져 나가 U+F642가 대신 나타났습니다. 대부분의 폰트가 상자나 아무것도로 렌더링하는 Private Use Area의 코드 포인트죠
폼 직렬화기는 반대 문제가 있었습니다. 문자를 wchar_t 하나씩 필터링하다가 고립되면 무효인 서러게이트 코드 유닛 둘을 보고 둘 다 버렸으므로, 이모지는 폼 패킷에서 완전히 사라졌습니다. v3.125.2에서 디코더는 각 스칼라 값을 온전히 소비하고 올바른 서러게이트 쌍을 내보냅니다. 출력 슬롯이 하나만 남으면 낮은 서러게이트를 보류해 두고 그 유닛이 아직 버퍼에 있는 동안 스트림 끝을 보고하지 않습니다. 읽기 블록에 걸쳐 갈라진 UTF-8 시퀀스는 버려지는 대신 다음 읽기로 넘어갑니다. 폼 익스포터는 이제 유효한 서러게이트 쌍을 함께 유지하고, 숫자 문자 참조도 올바른 쌍을 만들어 냅니다
Latin-1 테스트 데이터는 이 중 아무것도 보여 주지 않으므로, 모든 XFA 왕복 테스트에는 보충 평면 문자가 최소 하나 필요합니다
단일 스트림 XFA와 아무도 보지 못한 저장 실패
단일 스트림 XFA 문서는 편집을 잃었습니다. 네이티브 저장 헬퍼가 그 저장 레이아웃을 거부했는데 호출자가 실패를 무시했기 때문입니다. ISO 32000-1 §12.7.8은 대화형 폼 딕셔너리의 /XFA 엔트리가 패킷 이름과 스트림의 배열이거나 XDP 문서 전체를 담은 단일 스트림이거나 둘 중 하나를 허용합니다. 패킷 배열이 흔한 사례이지만 단일 스트림도 완전히 합법이고, 폼 데이터는 옛 값에 머물러 있는 동안 PDF 저장은 아무 일도 없었다는 듯 완료됐습니다
v3.125.2부터 V8 런타임은 지원되는 단일 스트림 부분집합을 다룹니다. 먼저 살아 있는 두 패킷, datasets와 form을 스테이징 영역으로 export해 검증한 뒤에야 원본 XDP의 해당 패킷들을 교체합니다. 다른 패킷과 루트 네임스페이스 선언은 유지됩니다. 스테이징이 실패하면 영속 XFA 스트림은 결코 만져지지 않고 문서는 수정 표시를 유지합니다
XML 주석과 처리 명령에는 각별한 주의가 필요했습니다. 내부 XML DOM이 그것들을 떨어뜨리기 때문입니다. v3.125.2에서는 그것들이 있으면 내용을 조용히 잃는 대신 저장이 곧장 실패했습니다. v3.126.0은 그것들을 보존합니다. 파싱 전에 각 주석이나 처리 명령은 원본 텍스트 어디에도 나오지 않는 접두로 만든 마커로 바뀝니다. 살아 있는 패킷들이 교체된 뒤 원본 토큰이 복원되고 스트림이 쓰이기 전에 모든 마커가 정확히 한 번 나타나야 합니다. 교체된 패킷 밖의 토큰은 따라서 프롤로그, 템플릿, 다른 패킷의 토큰을 포함해 텍스트와 순서를 유지합니다
어떤 입력은 여전히 의도적으로 거부되며, 각 거부는 명시적인 저장 실패입니다:
- 살아 있는
datasets나form패킷 안의 주석이나 처리 명령. 원래 위치를 갓 export된 콘텐츠로 사상할 수 없기 때문입니다 - DTD 선언과 XMLDSig 서명. XDP를 다시 쓰는 것으로는 XML 서명을 유효하게 유지할 수 없기 때문입니다
- 무효한 UTF-8이나 UTF-16 인코딩, 불완전한 태그, 무효한 문자 참조, 모르는 엔티티, 기형적인 처리 명령. 조용히 수리하는 대신 거부됩니다
단일 스트림 출력은 UTF-8이며 XML 콘텐츠 모델은 보존하지만 원본 바이트 레이아웃이나 인코딩 선언은 아닙니다
마지막 결함은 XFA 아래에 앉아 있었습니다. 네이티브 파일 writer는 출력을 32 KB 블록으로 버퍼링하고 마지막 부분 블록을 파괴자에서만 플러시했는데, 그것은 문서 writer가 이미 성공을 보고한 뒤였습니다. 그 마지막 블록의 디스크 가득 참이나 I/O 오류는 호출자에게 보이지 않았습니다. V8 런타임에서는 v3.125.2부터, 평범한 런타임에서는 v3.125.3부터 그 마지막 플러시는 저장 결과의 일부이고, XFA 수정 표시는 진짜 성공 뒤에만 지워집니다. Delphi 쪽에서 TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean은 대상 옆의 임시 파일에 쓰고 저장이 True를 돌려줄 때만 제자리로 옮기므로, 실패한 저장은 이전 파일을 온전하게 남깁니다
다이내믹 XFA 폼은 왜 더 적은 페이지로 재오픈될까?
다이내믹 XFA 폼이 더 적은 페이지로 재오픈되는 경우는 루트 서브폼이 restoreState="auto"를 선언하지 않았을 때이며, 그것은 PDFium Component 결함이 아니라 폼 저작 결정입니다. XFA 3.3에서 루트 서브폼의 restoreState는 기본값이 manual입니다. manual 아래에서 XFA 프로세서는 저장된 폼 패킷에서 제한된 상태만 복원하고 나머지는 저자의 스크립트에 맡깁니다. 저장된 필드 값과 반복 서브폼 인스턴스 수는 여전히 돌아오지만, 런타임에 설정된 기하 속성은 그렇지 않습니다
이것을 드러낸 사례는 스크립트가 서브폼을 h="450pt"로 키운 세 페이지 폼이었습니다. 저장된 폼 패킷은 새 높이, 값, 인스턴스 수를 담고 있었습니다. 하지만 재오픈에서는 레이아웃이 템플릿 높이에서 다시 빌드되고 폼은 두 페이지로 재유동했습니다. 런타임은 옳았습니다. 템플릿은 자동 복원을 한 번도 요청한 적이 없었으니까요. 루트 서브폼에 선언하면 재오픈이 고쳐집니다:
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form1" layout="tb" restoreState="auto">
<pageSet>
<pageArea name="Page1">
<contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
<medium stock="letter"/>
</pageArea>
</pageSet>
<subform name="Details" layout="tb" w="7.5in">
<!-- 필드, 스크립트는 런타임에 h를 바꾸거나 인스턴스를 추가할 수 있음 -->
</subform>
</subform>
</template>
템플릿을 소유하고 있지 않다면 뷰어에서 둘러싸고 고치지 마세요. manual 모드에 의존하는 폼은 자기 스크립트가 상태를 다시 빌드하기를 기대합니다. 사용자가 입력하는 동안의 실시간 재페이지매김은 별개 주제로, PDFium Component가 다이내믹 XFA 페이지 수와 이동한 필드를 추적하는 방법에서 다룹니다
Delphi에서 XFA 저장은 어떻게 검증하나?
믿을 수 있는 유일한 XFA 저장 검사는 저장된 파일을 새 TPdf 인스턴스에서 다시 열고 저장된 데이터를 읽어 돌려보는 것입니다. TPdf.GetXfaDatasets는 살아 있는 XFA 데이터 모델이 아니라 문서에 저장된 그대로의 datasets 패킷을 돌려주므로, 저장 전에 호출하면 옛 값을 보여 줍니다. 재오픈 뒤에는 쓰인 것을 정확히 보여 줍니다. 단일 스트림 문서에는 별도로 이름 붙은 패킷이 없습니다. PDFium은 XDP 전체를 빈 이름의 패킷 하나로 보고하므로, GetXfaPacketByName('datasets')과 GetXfaDatasets는 아무것도 돌려주지 않고 폴백은 GetXfaFormPackets를 통해 스트림 전체를 읽습니다
uses
System.SysUtils, PDFium, FPdfXfa;
function ReadSavedXfaData(const FileName: string): string;
var
Pdf: TPdf;
Packets: TXfaPacketList;
Bytes: TBytes;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
Bytes := Pdf.GetXfaDatasets; // 패킷 배열 레이아웃
if Length(Bytes) = 0 then
begin
Packets := Pdf.GetXfaFormPackets; // 단일 스트림: 이름 없는 패킷 하나
if Length(Packets) = 1 then
begin
SetLength(Bytes, Length(Packets[0].Content));
if Length(Bytes) > 0 then
Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
end;
end;
Result := TEncoding.UTF8.GetString(Bytes); // 저장된 XDP 출력은 UTF-8
finally
Pdf.Free;
end;
end;
저장 루틴은 이어서 대기 중인 편집을 커밋하고, SaveAs 결과를 검사하고, 재오픈한 값을 비교합니다. TPdf.ClearFormFieldFocus는 폼 포커스를 죽이는데, 그것이 PDFium이 포커스된 필드의 편집 버퍼를 커밋하는 순간입니다. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean은 포커스된 필드를 프로그래밍 방식으로 채우지만, 위젯 어노테이션을 훑는 FocusFormField를 통해 래퍼가 추적하는 포커스에 의존합니다. 다이내믹 XFA 페이지에는 보통 그런 것이 없으므로, 거기서는 텍스트가 보통 TPdfView의 키보드 입력으로 도착하고, 추적 중인 필드에 포커스가 없으면 함수는 False를 돌려줍니다
function XmlText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
end;
procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
Expected: string);
var
Saved: string;
begin
// 선택적 스크립트 채우기, False는 추적 중인 필드에 포커스가 없다는 뜻
if (Pdf.FocusedFormFieldIndex >= 0) and
not Pdf.SetFocusedFormFieldText(Expected) then
raise EPdfError.Create('Could not write the focused field');
Pdf.ClearFormFieldFocus; // 편집 버퍼 커밋
if not Pdf.SaveAs(FileName) then // 마지막 플러시 포함(v3.125.2+)
raise EPdfError.CreateFmt('Saving %s failed', [FileName]);
Saved := ReadSavedXfaData(FileName);
if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
Saved) = 0 then
raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;
부분 문자열 검사는 스모크 테스트로 다루세요. 빈 요소는 <Tag/>로 직렬화될 수 있고, 속성은 데이터 요소에 나타날 수 있으며, &와 < 너머의 이스케이프는 직렬화기의 선택입니다. 프로덕션 검사에서는 진짜 XML 파서로 재오픈한 XML을 읽어 들여 묶인 데이터 요소의 텍스트 노드를 비교하세요. 검사는 연달아 두 번 돌리세요. 줄바꿈 결함은 두 번째 세대에서야 전체 모습을 드러냈으니까요
빠른 참조: XFA 저장 충실도 체크리스트
- XFA 폼에는 v3.125.2 이상의
pdfium.v8.dll을, 평범한pdfium.dll에는 v3.125.3 이상을 배포할 것. 마지막 쓰기 수정이 양쪽에 들어 있습니다 LibraryName을 전체 경로로 가리키고EnableV8Engine을 True로 설정할 것. 없는 경로는 다른 사본을 로드하는 대신 실패합니다- 문서를 연 뒤
TPdf.XFA와TPdf.XfaRuntimeAvailable을 확인할 것 - 포커스된 필드가 커밋되도록
SaveAs전에ClearFormFieldFocus를 호출할 것 SaveAs의 Boolean 결과를 결코 무시하지 말 것. False 결과는 이전 파일을 제자리에 둡니다- 새
TPdf에서 다시 열고GetXfaDatasets를 읽어 검증할 것. 단일 스트림 XFA에는GetXfaFormPackets로 폴백 - 빈 값, 선행 공백, 여러 줄 텍스트,
&, 보충 평면 문자로 두 세대의 저장에 걸쳐 테스트할 것 - DTD, XMLDSig, 단일 스트림 XFA의 살아 있는 패킷 안의 주석에는 명시적인 저장 실패를 예상할 것
- 다이내믹 폼이 재오픈에서 런타임 기하를 잃으면 라이브러리를 의심하기 전에 루트 서브폼의
restoreState="auto"를 확인할 것
XFA 런타임이 호스트 애플리케이션에게 기대하는 콜백 구조체는 Delphi에서 FPDF_FORMFILLINFO version 2와 XFA ABI를 참조하세요. V8 런타임, Delphi와 C++Builder 래퍼, 뷰어 컨트롤은 모두 Delphi와 C++Builder용 PDFium Component의 일부이며, Win32와 Win64용 Windows 런타임 둘 다를 포함합니다