XML Forms Architecture인 XFA는 폐지되었습니다. ISO 32000-1은 §12.7에서 이를 다루면서 PDF 2.0에서는 제거되었다고 밝히고 있고, 최신 뷰어들은 XFA 엔진을 하나씩 걷어 내고 있습니다. 그렇다고 문서고가 비워진 것은 아닙니다. 정부 접수 양식, 보험 청약서, 은행 명세서가 20년 가까이 XFA로 저작되었고, 그 파일들은 오늘도 여전히 받은 편지함과 문서 파이프라인에 도착하고 있습니다. 그것들을 그려 주던 뷰어가 손을 놓으면 양식은 "다른 리더에서 열어 주십시오"라는 안내만 남은 빈 페이지가 됩니다. 오래가는 해법은 XFA를 어떤 리더든 그릴 수 있는 정적 PDF 콘텐츠로 평탄화하는 것입니다
그 평탄화에서 어려운 부분은 필드가 아닙니다. 텍스트 상자와 체크 상자는 AcroForm 위젯에 충분히 깔끔하게 대응됩니다. 어려운 부분은 XFA가 draw 요소 안, <exData contentType="text/html"> 블록에 담아 두는 리치 텍스트입니다. 그 블록은 인라인 스타일과, 흔히 앵커까지 포함한 HTML 부분집합입니다. 그것을 페이지에 올린다는 것은 스타일이 적용된 텍스트와 살아 있는 하이퍼링크를 모두 재현한다는 뜻이며, 대부분의 구현이 조용히 포기하는 지점이 바로 하이퍼링크입니다
XFA 리치 텍스트는 실제로 어떤 모습인가
exData 본문은 XHTML의 작은 조각입니다. 문단은 <p>이고, 스타일이 적용된 문자 구간은 굵기, 기울기, 색, 크기를 위한 자체 인라인 CSS를 지닌 <span>이며, 하이퍼링크는 보이는 텍스트를 감싸는 <a href="...">입니다. 한 줄에 서로 다른 스타일의 span이 여럿 연달아 놓일 수 있고, 그중 하나가 앵커일 수 있습니다. 이 스타일은 버려도 되는 장식이 아닙니다. 법적 경고라서 굵은 빨간색으로 그려진 조항은 평탄화 뒤에도 굵고 빨간색으로 남아야 하며, 그러지 않으면 평탄화된 문서가 원본을 잘못 전하게 됩니다
그래서 평탄화 엔진은 그 블록을 문자열 하나로 다룰 수 없습니다. 인라인 구조를 순회하며 각 런의 실효 스타일을 span의 인라인 CSS를 draw 요소의 기본 폰트 위에 겹쳐 해석하고, 런들을 줄을 따라 차례로 배치해야 합니다. HotPDF는 이렇게 배치된 각 조각을 내부 TXFARichRun 레코드로 모델링합니다. 이 레코드는 런의 텍스트, 해석된 스타일, 측정된 상자를, 그리고 앵커라면 그것이 가리키는 Href를 담습니다
런을 왼쪽에서 오른쪽으로 배치하기
위치 잡기에서 리치 텍스트는 파싱 문제이기를 그치고 조판 문제가 됩니다. 런들은 한 줄을 공유하므로 각 런은 앞선 런이 끝난 자리에서 시작합니다. 그 위치를 기록해 둔 마크업은 없습니다. 측정해야 합니다. 엔진의 내부 LayoutRichText 루틴은 나중에 그것을 그릴 때와 같은 폰트 메트릭으로 모든 런을 측정한 다음, 런의 가로 오프셋을 앞선 모든 런 폭의 누적합으로 설정합니다. 첫 런은 draw 상자 원점에서 시작하고, 둘째 런은 첫 런의 폭에서, 셋째 런은 앞선 둘의 폭 합에서 시작하는 식으로 줄을 따라 이어집니다
측정용 폰트를 맞추는 일이 그토록 중요한 이유가 여기 있습니다. 레이아웃 패스는 진행량을 측정하고, 별도의 렌더 패스가 글리프를 그립니다. 두 패스가 폰트에 대해 서로 다른 판단을 하면, 레이아웃이 계산한 상자는 렌더러가 그리는 글리프 아래에 놓이지 않습니다. HotPDF는 내부 RunStyleToFontSpec 헬퍼를 통해 각 런의 해석된 스타일을 렌더러 자신의 기본값인 10포인트 Arial과 맞아떨어지는 폰트 명세로 대응시켜 둘의 보조를 맞춥니다. 그러면 측정된 진행량과 그려진 텍스트가 일치하고, 런의 계산된 상자가 독자가 보는 문자를 실제로 덮게 됩니다
// 배치된 런 하나의 개념적 모양. 엔진이 이런 레코드의 배열을 내부에서
// 만들며, 여러분이 직접 구성할 일은 없습니다. 다만 이 필드들은 링크의 히트
// 박스가 텍스트가 아니라 측정된 기하에서 도출됨을 설명해 줍니다.
type
TRichRunInfo = record
Dx, Dy : Double; // draw 상자 원점 기준 좌상단
W, H : Double; // 측정된 런 상자(폭은 레이아웃 패스에서)
Text : AnsiString; // 런의 보이는 문자들
Href : AnsiString; // <a> 런의 URI 대상, 그 외에는 빈 문자열
end;
앵커 런에서 PDF Link 주석으로
완성된 PDF의 하이퍼링크는 페이지 콘텐츠의 일부가 아닙니다. ISO 32000-1 §12.5.6.5가 기술하는 별개의 객체, 즉 Link 주석입니다. 이 주석에는 페이지에서 클릭 가능한 사각형을 정의하는 /Rect와, 그 사각형이 클릭될 때 발동하는 액션이 있습니다. 외부 링크라면 액션은 URI 액션입니다. 대상 주소를 /URI 문자열로 지닌 /S /URI입니다. 그 아래 보이는 텍스트는 평범한 페이지 콘텐츠이고, 주석은 그 위에 덮인 보이지 않는 반응 영역입니다
평탄화 경로는 정확히 이 모델을 따릅니다. 런에 Href가 있으면 HotPDF는 먼저 스타일이 적용된 텍스트를 그린 다음, 그 런의 상자 위에 Link 주석을 만듭니다. 그 주석의 공개 진입점은 페이지 메서드 AddURILink이며, /URI 액션을 지닌 /Type /Annot /Subtype /Link 객체를 만들어 주석 딕셔너리를 반환합니다. 그 사각형은 런의 측정된 상자를 draw 요소의 지역 좌표에서 페이지 좌표로 옮긴 것입니다. 그 결과는 앵커 텍스트에 정확히 얹히고 다른 곳에는 얹히지 않는 링크입니다
// 평탄화 경로가 앵커 런마다 쓰는 것과 같은 공개 API입니다. 주어진 사각형
// 위에 /URI 액션을 지닌 /Subtype /Link, 즉 ISO 32000-1 12.5.6.5 Link
// 주석을 만들어 냅니다. 선택적 설명은 /Contents를 채우므로 스크린 리더가
// 대상을 읽어 줄 수 있습니다.
var
LinkRect: TRect;
Annot: THPDFDictionaryObject;
begin
LinkRect := Rect(72, 690, 268, 706); // 런의 페이지 공간 히트 박스
Annot := Pdf.CurrentPage.AddURILink(LinkRect,
'https://www.example.gov/appeal', 'File an appeal online');
end;
히트 박스가 측정된 폭에서 나와야 하는 이유
보이는 텍스트로 페이지를 검색해 링크 위치를 찾고 찾아낸 것 주위에 사각형을 그리면 되지 않을까 상상하기 쉽습니다. 그 방법은 통하지 않으며, 이유는 평탄화된 텍스트가 저장되는 방식 자체에 있습니다. 스타일이 적용된 런은 임베드된 서브셋 폰트로 그려집니다. 서브셋 폰트는 남긴 글리프에 번호를 다시 매기므로, 페이지 콘텐츠 스트림에는 원래 문자 코드가 아니라 16진수 CID 코드가 담깁니다. 페이지 위의 바이트는 사람이 읽는 글자가 아니고, 텍스트로 검색되지도 않습니다. 앵커의 문구를 검색해 봐야 아무것도 찾지 못합니다. 그 문구는 스트림 어디에도 문자 그대로의 텍스트로 존재하지 않기 때문입니다
사각형이 기댈 수 있는 유일하게 믿을 만한 근거는 레이아웃 패스가 이미 만들어 낸 기하입니다. 각 런의 오프셋과 측정된 폭은 글리프에 번호가 다시 매겨지기 전, 줄을 흘리는 동안 계산되었고, 텍스트가 물리적으로 어디에 나타날지를 서술합니다. 그래서 HotPDF는 링크 사각형을 어떤 텍스트 검색이 아니라 런의 배치된 상자에서 곧바로 가져옵니다. 측정에 렌더 폰트를 썼기 때문에 서브셋 여부와 상관없이 상자는 정확합니다. 기하는 인코딩을 견디고 텍스트는 견디지 못합니다. 측정 폭 기반 위치 잡기를 옹호하는 논거는 그것이 전부이며, 텍스트 검색으로 링크를 나중에 끼워 넣으려는 평탄화기가 흔들리거나 사라지는 반응 영역을 만들어 내는 이유이기도 합니다
코드에서 평탄화 구동하기
이미 XFA 패킷을 담고 있는 PDF라면 진입점은 FlattenLoadedXFA입니다. 문서를 로드하고, 메서드를 호출하고, 결과를 저장하면 됩니다. Editable 매개변수가 폼 필드의 운명을 결정합니다. True를 넘기면 입력 가능한 AcroForm 위젯으로 남고, False를 넘기면 모든 위젯이 읽기 전용으로 표시되어 출력물이 고정된 기록이 됩니다. 스타일이 적용된 런과 링크 주석을 담은 리치 텍스트 draw 블록은 어느 쪽이든 만들어집니다. 함수는 자신이 내보낸 위젯 수를 반환합니다
var
Pdf: THotPDF;
Emitted, i: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('xfa_appeal_form.pdf');
// True는 필드를 입력 가능하게 두고, False는 읽기 전용으로 고정합니다.
Emitted := Pdf.FlattenLoadedXFA(True);
// 엔진이 대응시키지 못한 것은 예외가 아니라 보고로 남습니다.
for i := 0 to Pdf.XFAFlattenWarnings.Count - 1 do
Writeln('XFA warning: ', Pdf.XFAFlattenWarnings[i]);
Pdf.SaveLoadedDocument('appeal_form_flat.pdf');
Writeln('Widgets emitted: ', Emitted);
finally
Pdf.Free;
end;
end;
호출 뒤에는 언제나 XFAFlattenWarnings를 읽으십시오. 이 목록은 평탄화가 시작될 때마다 비워지고, 엔진이 그리기를 사양한 요소마다 한 줄씩 쌓입니다. 지원되지 않는 필드 종류, 디코딩되지 않는 draw 이미지, 쓸 만한 span이 없는 exData 블록 같은 것들입니다. 그중 어느 것도 예외를 일으키지 않으므로, 비어 있는 경고 목록이야말로 모든 것이 대응되었다는 증거이고, 비어 있지 않은 목록은 어느 원본을 들여다봐야 하는지 정확히 알려 줍니다. 로드된 PDF가 아니라 XDP 바이트 형태의 원시 XFA를 들고 있다면, 형제 메서드 ApplyXFAAsAcroForm이 그 바이트를 직접 받아 같은 코드 경로와 같은 경고 동작을 공유합니다. 짝을 이루는 AddXFAPacket 메서드는 반대 방향으로, 여러분이 만들고 있는 문서에 XFA 패킷을 임베드합니다
리더에서 결과 확인하기
평탄화된 파일을 Acrobat이나 최신 뷰어에서 열어 두 가지를 확인하십시오. 첫째, 리치 텍스트가 스타일을 온전히 지닌 채 그려졌는지입니다. 굵은 런은 굵고, 색이 있는 런은 색을 지니며, span들이 겹치거나 상자 밖으로 튀어나가지 않고 줄 위에 올바른 순서로 앉아 있어야 합니다. 둘째, 하이퍼링크가 살아 있는지입니다. 앵커 위에 마우스를 올리면 상태 표시줄에 대상 주소가 나타나야 하고, 클릭하면 URI 액션이 그것을 열어야 합니다. 뷰어의 주석 검사기로 각각이 진짜 /Link 주석인지, 그 /Rect가 앵커 텍스트에 딱 붙어 있는지, 그리고 그 아래 콘텐츠가 이제 폼으로 그려지는 XFA가 아니라 그냥 그려진 글리프인지 확인하십시오. 스타일이 살아 있는 정적 텍스트에 올바른 사각형 위의 진짜 Link 주석이 더해진 그 조합이, 더 이상 필요하지 않은 XFA 엔진보다 평탄화된 문서가 더 오래 살아남게 해 줍니다
이 리치 텍스트를 둘러싼 텍스트 상자, 체크 상자, 선택 목록 같은 필드 자체의 평탄화는 XFA 폼을 AcroForm 위젯으로 평탄화하는 안내에서 다룹니다. 평탄화 경로가 만들어 내는 것 너머로, Link 주석을 손수 만들고 배치하는 더 넓은 이야기는 HotPDF에서 PDF 주석 다루기를 보십시오. 둘 다 Delphi 및 C++Builder용 HotPDF Delphi Component에 함께 제공되는 같은 주석 및 폼 모델 위에 서 있습니다