기술 문서

Delphi PDFium Component: PDF 폼 탭 순서와 /Tabs

3.223.0부터 Delphi용 PDFium Component는 각 페이지가 선언한 /Tabs 정책으로 양식 필드 탐색 순서를 정한다. R은 행 순서, C는 열 순서, S는 구조 순서에 더해 PDF 2.0의 값 A와 W다. TPdf.FormFieldTabOrder가 그 순서를 돌려주고, status 오버로드는 선언된 정책이 적용됐는지 폴백이 쓰였는지 보고한다

이 모든 것을 움직인 불평은 언제나 같다. 사용자가 두 열짜리 보험 양식을 채우다 이름 필드 뒤에서 Tab을 누르면 커서가 오른쪽 열 맨 아래로 점프한다. 인덱스에는 아무 잘못도 없다. 생산자가 단지 오른쪽 열을 /Annots에 먼저 기록하고 시각 순서를 고치려고 페이지에 /Tabs /R을 선언했을 뿐인데, /Annots를 걷고 선언을 무시하는 뷰어는 엉뚱한 곳에 내려앉는다. 위젯 인덱스와 어노테이션 인덱스에 관한 글이 위젯 서수가 /Annots 배열과 어떻게 관련되는지 다룬다. PDFium Component에서는 선언된 정책이 확립될 수 있는 한 언제나 이기고, 확립될 수 없을 때는 컴포넌트가 이유를 말해 준다

/Tabs 항목은 실제로 무엇을 선언하는가?

/Tabs는 페이지 객체의 선택적 이름으로(ISO 32000-2 §7.7.3.3; ISO 32000-1의 Table 30), 프로세서에게 그 페이지의 어노테이션을 어떤 순서로 방문할지 말해 주며 그 의미론은 §12.5.1의 어노테이션 절에 상술돼 있다. PDF 1.5가 세 값을 도입했고 PDF 2.0이 두 값을 더했다

  • /R, 행 순서: 위에서 아래로 행을 훑고 각 행 안에서는 뷰어의 읽기 방향
  • /C, 열 순서: 읽기 방향으로 열을 훑고 각 열 안에서는 위에서 아래로
  • /S, 구조 순서: 논리 구조 트리가 어노테이션을 참조하는 순서
  • /A, 어노테이션 배열 순서: /Annots 자체의 순서(PDF 2.0)
  • /W, 위젯 순서: 배열 순서의 위젯을 먼저, 그다음 나머지 어노테이션(PDF 2.0)

손작성 구현을 넘어지게 하는 디테일은 상속이다. /Tabs는 페이지 사전에만 속한다. ISO 32000-2 §7.7.3.4는 상속 가능한 페이지 속성을 Resources, MediaBox, CropBox, Rotate로 제한하므로 /Pages 노드에 앉은 /Tabs 키는 무의미하고, 부모 사슬을 올라가 하나를 찾는 리더는 저자가 세운 적 없는 정책을 발명하는 것이다. PDFium Component는 /Tabs를 페이지 자체에서만 읽는다

FormFieldTabOrder로 선언된 순서 읽기

TPdf.FormFieldTabOrder는 현재 페이지의 양식 필드를 선언된 탭 순서의 0 기반 위젯 서수로 돌려주고, out Status: TPdfFormTabOrderStatus 파라미터를 지닌 오버로드는 그 순서가 어떻게 만들어졌는지 설명한다. 같은 쌍이 TPdfView에도 있고, 평범한 오버로드는 status를 버리면서 동일한 정책을 쓴다

uses
  PDFium;

const
  ModeNames: array[TPdfFormTabOrderMode] of string =
    ('unspecified', 'R', 'C', 'S', 'A', 'W', 'unsupported');

procedure DumpTabOrder(const FileName: string; PageNo: Integer);
var
  Pdf: TPdf;
  Order: TPdfIntegerArray;
  Status: TPdfFormTabOrderStatus;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.FormFill := True;          // 이미 기본값이다, 필드에 필요하다
    Pdf.Active := True;
    if not Pdf.Active or (Pdf.FormType <> ftAcroForm) then
      Exit;
    Pdf.PageNumber := PageNo;
    Order := Pdf.FormFieldTabOrder(Status);
    Writeln('requested ', ModeNames[Status.RequestedMode],
      ', applied ', ModeNames[Status.EffectiveMode]);
    if Status.UsedFallback then
      Writeln('fallback: ', Status.ErrorMessage);
    if Status.UnstructuredWidgetCount > 0 then
      Writeln(Status.UnstructuredWidgetCount, ' widgets not in the structure tree');
    for I := 0 to High(Order) do
      Writeln(I, ': field ', Order[I], ' ',
        string(Pdf.FormFieldInfo[Order[I]].Name));
  finally
    Pdf.Free;
  end;
end;

status 레코드가 조용한 순서 결정을 기록할 수 있는 무언가로 바꾼다. RequestedMode는 페이지가 선언한 것이고, EffectiveMode는 적용된 것이며, UsedFallback는 선언된 정책을 존중할 수 없어 어노테이션 배열 순서로 돌아왔을 때 참이다. 짧은 이유가 ErrorMessage에 들어간다. SnapshotAvailable은 컴포넌트가 문서 메타데이터를 아예 읽을 수 있었는지 말해 준다. 빠진 /Tabs는 선언된 정책으로 둔갑시키지 않고 어노테이션 배열 순서로의 폴백으로 보고되므로, 지원 로그가 '저자가 이 순서를 요청했다'와 '페이지가 아무것도 요청하지 않았다'를 가를 수 있다

페이지 /Tabs 정책 값 R, C, S, A, W와 선언된 순서가 적용되거나 폴백할 때 Delphi용 PDFium Component가 보고하는 status 필드
빠진 /Tabs는 선언된 정책으로 둔갑한 것이 아니라 보고되는 어노테이션 배열 순서 폴백이다

행과 열 순서는 왜 /Rect 값을 정렬하는 것만으로 안 되는가?

행과 열 순서는 원시 어노테이션 좌표가 아니라 표시 기하학에서 계산돼야 한다. 페이지의 /Rotate와 문서의 읽기 방향이 둘 다 '행'의 의미를 바꾸기 때문이다. /Rotate 90 페이지의 /Rect 배열을 y 좌표로 정렬하면 뷰어가 열로 보여 주는 것을 얻는다. 컴포넌트는 따라서 모든 위젯 사각형을 페이지의 표시 변환, 고유 회전 포함으로 1/64 포인트 해상도에서 사상하고 거기서부터 묶는다

읽기 방향은 카탈로그의 ViewerPreferences 사전(ISO 32000-2 §12.2)에서 온다. /Direction /R2L은 행 안의 순서와 열들의 순서를 뒤집는데, 아랍어나 히브리어 양식이 기대하는 바로 그것이다. /Direction이 없으면 L2R이 적용된다. L2R이나 R2L 이외의 무엇이든 들어 있으면 행과 열 순서는 status에 이유를 남기고 어노테이션 배열 순서로 폴백한다. 방향을 지어내는 편이 없다고 인정하는 것보다 나쁘기 때문이다

묶기는 프로세서들이 정당하게 갈리는 자리이므로 정확한 규칙을 알아 둘 가치가 있다. 행 순서에서는 아직 방문하지 않은 위젯 중 가장 위쪽이 앵커가 되고, 수직 중심이 앵커의 위·아래 안으로 떨어지는 모든 위젯이 그 행에 합류한다. 열 순서는 이를 수평으로 거울상처럼 반영한다. 동점은 위젯 서수로 결정적으로 갈린다. 깔끔한 격자에서는 어떤 상식적인 뷰어와도 일치한다. 심하게 겹치거나 어긋난 필드의 레이아웃에서는 다른 허용 오차를 쓰는 다른 프로세서가 다르게 묶을 수 있고, ISO 32000의 어느 부분도 어느 쪽 답을 틀렸다고 하지 않는다

Delphi의 PDFium Component: 어노테이션 배열 탭 순서는 오른쪽 열로 점프해 내려가는 반면 선언된 /Tabs R 행 순서는 두 열 양식을 행마다 방문한다
행 순서는 /Rotate와 읽기 방향 뒤의 표시 기하학에서 계산되지, 날 /Annots 순서에서 계산되는 게 아니다

구조 순서는 어떻게 순서를 결정하는가?

구조 순서는 구조 트리의 객체 참조(OBJR) 사전을 따르는데, /K 배열의 깊이 우선 순회가 실제로 닿는 순서로 따른다. /ParentTree 키 순서를 따르지 않고, StructTreeRoot 열거가 우연히 내놓는 순서도 따르지 않는다. 그런 순서들은 단일 생산자의 파일에서는 흔히 일치하다가 편집기가 트리를 재배치한 뒤 갈라진다. 태그된 양식의 읽기 순서가 가장 중요한 순간이 정확히 그때다. 그 트리를 직접 관리한다면 /ParentTree와 /StructParents 동기화 유지하기 가이드가 그와 일치해야 하는 역방향 매핑을 다룬다

구조 트리가 결코 참조하지 않는 위젯은 명세가 프로세서에게 맡긴다. PDFium Component는 그것들을 구조화된 위젯 뒤에 어노테이션 배열 순서로 덧붙이고 UnstructuredWidgetCount에 몇 개를 덧붙였는지 보고한다. 구조 트리가 전혀 없는 문서에서 /S를 선언한 페이지는 빈 순회로 취급된다. 모든 위젯이 그 꼬리에 앉고 count가 그 사실을 말하며, 결과는 전체 폴백으로 표시되지 않는다. 순환이 있는 구조 트리, 두 번 참조된 OBJR, 자기 어노테이션을 소유하는 페이지와 /Pg가 상충하는 OBJR은 구조 순서를 폴백시킨다. 그 결함들은 S를 쓸모없게 만들지만 다른 페이지의 R이나 C는 흔들지 않는다

탭 순서 값은 왜 어노테이션 인덱스가 아닌가?

FormFieldTabOrder가 돌려주는 배열의 값은 위젯만 센다. 반면 OBJR은 전체 /Annots 배열에 사는 어노테이션 객체를 식별한다. 두 인덱스 공간을 섞는 것이 구조 순서를 소비하는 어떤 코드에서든 가장 개연성 높은 버그다. /Annots가 [Link, Widget A, Widget B]인 페이지를 상상하라. Widget B의 OBJR은 어노테이션 인덱스 2를 가리킨다. Widget B는 필드 서수 1이다. 2를 FocusFormField에 먹이면 페이지가 위젯을 몇 개 지녔는지에 따라 엉뚱한 필드에 포커스를 주거나 아무것도 주지 못한다

구조 OBJR은 어노테이션 인덱스 2를 겨냥하는 반면 Delphi의 PDFium Component 폼 API는 위젯을 세므로 Widget B는 필드 서수 1이다
어노테이션 인덱스 2는 필드 서수 1이 아니다 — FocusFormField를 부르기 전에 명시적으로 변환해라

컴포넌트는 그 변환을 내부에서 수행하므로 그 결과가 FocusFormField와 FormFieldInfo에 곧장 들어간다. 여러분 코드가 OBJR 대상이나 다른 어노테이션 인덱스를 읽는다면 필드로 취급하기 전에 명시적으로 변환해라

// 페이지 어노테이션 인덱스를 FocusFormField, FormFieldInfo와
// FormFieldTabOrder가 쓰는 위젯 서수로 변환한다, 위젯이 아니면 -1
function AnnotationToFieldOrdinal(Pdf: TPdf; AnnotIndex: Integer): Integer;
var
  I: Integer;
begin
  Result := -1;
  if (AnnotIndex < 0) or (AnnotIndex >= Pdf.AnnotationCount) then
    Exit;
  if Pdf.Annotation[AnnotIndex].Subtype <> anWidget then
    Exit;                          // 링크, 노트, 팝업은 필드가 아니다
  Result := 0;
  for I := 0 to AnnotIndex - 1 do
    if Pdf.Annotation[I].Subtype = anWidget then
      Inc(Result);
end;

결과는 페이지가 로드돼 있고 편집되지 않은 동안에만 유효한 것으로 취급하라. 위젯 앞에서 어노테이션을 추가하거나 제거하면 두 인덱스 공간이 모두 밀리고, 편집 전의 캐시된 매핑은 고객 파일에서만 재현되는 그런 off-by-one이다

FocusNextFormField로 페이지를 넘나드는 탐색

FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256)는 현재 페이지의 탭 순서를 걷다가 이후 페이지로 이어가며, 3.223.0부터 방문하는 모든 페이지가 자기 선언 정책을 적용한다. 페이지 간 오버로드는 3.220.0에 도착했으므로 감싸기를 감지하고 스스로 PageNumber를 바꾸라는 양식 필드 탐색 글의 조언은 이제 한 페이지 안에서만 순환하는 인자 없는 오버로드에만 적용된다

procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
  Field: Integer;
begin
  if ssShift in Shift then
    Field := PdfView.FocusPreviousFormField(True)  // 페이지를 넘나들며 감싼다
  else
    Field := PdfView.FocusNextFormField(True);
  if Field < 0 then
    Beep   // 스캔 안에 자격 있는 것이 없다, 페이지와 포커스 불변
  else
    UpdateFieldStatus(PdfView.PageNumber, Field);
end;

돌려주는 값은 이제 포커스를 쥔 페이지의 페이지 로컬 필드 서수이므로 PageNumber를 나란히 읽어라. 페이지 간 순회는 사용자가 의미 있게 탭으로 들어갈 수 없는 위젯을 건너뛴다. hidden, invisible, NoView, read-only, signature, 미지 유형, 빈 사각형 위젯이 그렇다. WrapDocument = False는 문서 경계에서 -1로 멈춘다. MaxPagesToScan은 1부터 4096 사이여야 하고, 현재 페이지 포함 검사된 모든 페이지가 셈에 든다. 스캔이 아무것도 찾지 못하거나 네이티브 엔진이 대상에 포커스를 거부하면 호출은 -1을 돌려주고 이전 페이지와 포커스를 정확히 그대로 둔다

선언된 순서는 어디서 적용을 멈추는가?

선언된 정책은 컴포넌트가 페이지 메타데이터를 믿을 만하게 읽을 수 있을 때만 존중되며, 그럴 수 없는 모든 사례는 명시적 이유와 함께 어노테이션 배열 위젯 순서로 폴백한다. 경계는 나열할 만큼 구체적이다

  • 메타데이터는 원본 파일 바이트가 아니라 현재 네이티브 문서의 경계가 있는 사적 직렬화에서 온다. 메모리 안의 페이지 이동과 편집이 디스크의 파일을 낡게 만들기 때문이다. 스냅샷은 콘텐츠 리비전마다 캐시되므로 통지된 편집이 이를 무효화하고, 실패한 스냅샷도 캐시된다
  • 암호화된 문서와 페이지 객체 편집 중에 요청된 스냅샷은 스냅샷이 없어 언제나 폴백한다
  • 스냅샷은 64 MiB, 활성 객체 65536, 페이지 4096, 페이지당 어노테이션 4096, 전체 어노테이션 65536, 구조 깊이 64로 제한된다. 더 큰 문서는 탐색을 막는 대신 폴백한다
  • AcroForm 문서만 커버된다. XFA 양식은 이 프로파일 밖에 머문다
  • 행과 열 순서에는 유한하고 비어 있지 않은 위젯 사각형과 상식적인 페이지 크기가 필요하다. 퇴화한 기하학은 폴백을 촉발한다
  • 이 API들은 여러분 코드에서 탐색을 몰아간다. 엔진에 전달된 Tab 키에 대한 PDFium 자체 처리는 대체되지 않으므로 선언된 순서를 원하면 키를 FocusNextFormField로 경유시켜라

조회는 현재 페이지, 네이티브 포커스, 포커스된 필드의 대기 중 편집 텍스트를 보존하고 문서나 페이지 수명 주기 동작을 돌리지 않는다. 따라서 키 입력마다 FormFieldTabOrder를 부르는 것이 안전하고, 주어진 콘텐츠 리비전에서 첫 호출 뒤로는 값싸다

선언된 탭 순서, status 진단, 페이지 간 포커스 탐색은 Delphi 및 C++Builder용 PDFium Component의 일부이며, 제품 페이지가 TPdfFormTabOrderMode와 TPdfFormTabOrderStatus 항목을 포함한 전체 API 참조로 이어 준다