기술 문서

Form XObject 재귀: Delphi PDFlibPas의 순환 감지

PDFlibPas는 전역 방문 집합이 아니라 활성 호출 체인을 추적함으로써 Delphi PDF 콘텐츠 스트림 안의 재귀적 Form XObject 호출을 해석한다. 그래서 TPDFlib.EnumPageContentStatesEx는 한 페이지에서 여러 번 호출된 같은 Form을 합법적인 재사용을 순환으로 오인하지 않고 순회할 수 있다. 인보이스 템플릿의 스탬프 Form XObject가 전형적인 경우다: 같은 객체가 한 페이지의 헤더, 푸터, 워터마크 레이어에서 호출되며, 자기 자신으로 되돌아가는 호출 체인만이 진짜 순환이다

ISO 32000-1 §8.10은 Form XObject를 페이지나 다른 Form이 Do 오퍼레이터로 호출하는 독립적인 콘텐츠 스트림으로 정의하며, /Matrix에 자신만의 좌표계를, 그 좌표계 안의 /BBox에 클리핑 경계를, 선택적으로 자신만의 리소스 딕셔너리를 갖춘다. 스펙 어디에도 하나의 Form이 몇 번 호출될 수 있는지, 또는 Form들이 서로를 얼마나 깊이 호출할 수 있는지에 대한 상한이 없다. 그래서 표준을 준수하는 파서는 합법적인 재사용과 합법적인 중첩을 받아들이면서도 스펙이 실제로 금지하는 단 하나의 배치, 즉 자신의 콘텐츠 스트림이 직접적으로든 전이적으로든 자기 자신을 호출하는 Form에 대해서는 스스로를 방어해야 한다. PDFlibPas는 이 구분을 모든 Do 스냅샷에 붙는 TPDFlibContentFormTraversalStatus 값들로 보고하는데, 성공적인 하강에는 ftsEnumerated, 실제로 루프인 경우에는 ftsCycle이 가장 눈에 띈다

같은 Form XObject를 재사용해도 왜 거짓 순환을 촉발하지 않는가?

반복된 Form XObject 참조는 그 자체로는 잘못된 무언가의 증거가 아니다. ISO 32000-1은 작성자가 원하는 만큼 콘텐츠 스트림 안 여러 곳에서 같은 Form 객체를 호출하도록 허용하며, 이것이 정확히 로고 스탬프, 레터헤드 템플릿, 페이지 번호 푸터가 콘텐츠 스트림을 여러 번 중복하지 않고도 페이지에 걸쳐 재사용되는 방법이다. 폭주하는 재귀에 대한 순진한 방어는 객체 번호를 키로 하는 단일 방문 집합이다: 순회자가 Form 객체 12를 처음 보는 순간 12를 본 것으로 표시하고 트리의 다른 어디에서도 다시 들어가는 것을 거부한다. 이 접근은 같은 스탬프가 한 페이지의 서로 무관한 두 모서리에 나타나는 순간 무너지는데, 두 번째의 완전히 합법적인 호출은 그 객체 번호가 이미 본 것으로 표시된 뒤 도착해 마치 루프인 것처럼 거부되기 때문이다

PDFlibPas는 순환 감지 범위를 전체 문서가 아니라 현재 호출 체인으로 한정함으로써 이 오탐을 피한다. EnumPageContentStatesEx는 해석된 Form 스트림을 그 안으로 하강하기 직전에 활성 호출 체인에 밀어 넣고, 성공했든 아니든 하강이 돌아오는 순간 그 같은 항목을 다시 꺼낸다. 동일한 스트림의 형제 호출은 첫 번째 것이 이미 꺼내진 뒤에야 시작되므로, 형제 호출이 이를 확인할 무렵에는 호출 체인에 그 스트림이 남아 있지 않고, 순회자는 다른 어떤 Form과 마찬가지로 이를 열거한다. 진짜 순환은 같은 체인에서 다르게 보인다: Form A가 Form B를 호출하고, B가 자신의 콘텐츠에서 다시 A를 호출할 때 B는 여전히 체인에 열려 있으며, A도 아직 돌아오지 않은 바깥 호출로부터 여전히 체인에 앉아 있다 — 이것이 ftsCycle이 보고하는 유일한 형태로, 페이지의 다른 어딘가에 그저 존재하는 것이 아니라 현재 호출 체인의 더 이전 어딘가에 여전히 열려 있는 Form 스트림이다

PDFlibPas가 멈추기 전 Form XObject 재귀는 얼마나 깊어질 수 있는가?

순환 감지와 깊이 제한은 서로 다른 두 문제를 해결하며, PDFlibPas가 이 둘을 서로 다른 두 TPDFlibContentFormTraversalStatus 결과로 유지하는 것은 바로 그 이유 때문이다. 20개의 서로 다른 Form이 각각 다음 것을 호출하고 어느 것도 반복하지 않는 체인은 어떤 정의로도 순환이 아니다 — 활성 체인 검사는 반복되는 스트림을 결코 발견하지 못한다 — 하지만 정직한 20단계 중첩도 여전히 20단계의 파싱, 행렬 연결, 리소스 해석이며, 잘못된 형식이거나 적대적인 PDF는 다른 무언가가 멈추지 않으면 이를 임의로 더 높이 밀어붙일 수 있다. EnumPageContentStatesEx는 정확히 이 이유로 MaxFormDepth 매개변수를 받으며, 호출자가 무엇을 요청하든 최대 64로 값을 제한한다. 깊이 0은 그 자체로 알아둘 가치가 있는 특수한 경우다: 이는 Form 재귀를 완전히 비활성화하고 더 오래된 EnumPageContentStates 메서드의 평면적인, 페이지만 다루는 동작을 재현한다. 이것이 그 모드의 모든 Do 스냅샷이 무언가를 시도하는 대신 ftsNotRequested를 보고하는 이유다

var
  Lib: TPDFlib;
  States: array of TPDFlibContentGraphicsState;
  Count, I: Integer;
begin
  Lib:= TPDFlib.Create;
  try
    if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
      Exit;
    Lib.SelectPage(1);
    Count:= Lib.EnumPageContentStatesEx(True, 8, States);  // count only
    SetLength(States, Count);
    Lib.EnumPageContentStatesEx(True, 8, States);          // fill
    for I:= 0 to Count- 1 do
      if States[I].FormTraversalStatus= ftsCycle then
        LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
  finally
    Lib.Free;
  end;
end;

호출당 하나의 서브 트래커: 그래픽 상태 격리하기

Form XObject로의 모든 하강은 이미 페이지를 순회하고 있는 것을 공유하는 대신 자신만의 그래픽 상태 트래커를 얻는데, Form의 콘텐츠 스트림은 발견했을 때와 정확히 같은 상태로 그래픽 상태를 남겨야 하지만 PDFlibPas는 자신이 여는 모든 PDF가 실제로 그 요구사항을 지킨다고 가정할 수 없기 때문이다. 자식 트래커는 호출하는 Do 명령에서 활성화되어 있던 CTM, 색상 상태, 텍스트 매개변수의 스냅샷에서 시작한 다음, Form의 명령을 하나라도 실행하기 전에 자신의 저장-복원 스택과 현재 경로 추적을 빈 상태로 초기화한다. 짝이 되는 Q가 없는 불균형한 q가 부주의하거나 손상된 Form 안에 있는 것 — 구형 도구가 만든 PDF에서 드물지 않게 발견된다 — 은 그 하나의 호출의 트래커 안에 갇힌 채로 남으며, 페이지 트래커나 콘텐츠 스트림 한 줄 뒤에 있는 같은 스탬프의 형제 호출로 절대 새어 나가지 않는다

Form /MatrixDo에서 유효한 CTM과 cm 오퍼레이터가 하는 것과 같은 방식으로 결합하며, 현재 변환을 대체하는 대신 그것에 왼쪽 곱해진다. PDFlibPas는 두 번째 공식을 유지하는 대신 의도적으로 그 하나의 코드 경로를 재사용하는데, 같은 행렬 대수의 두 독립적인 구현이야말로 확대, 회전, 전단 합성을 몇 라운드 거친 뒤 조용히 서로 어긋나는 종류의 중복이기 때문이다. 그런 다음 /BBox는 행렬이 이미 적용된 뒤 Form 자체의 좌표 공간에서 클리핑하며, 그 상자의 네 모서리 모두가 반대편 모서리 둘만이 아니라 각각 개별적으로 변환된다. 회전되거나 전단된 Form은 그렇지 않으면 변환이 그것을 다른 곳으로 옮기기 전에는 극단적인 모서리였던 곳에 앉아 있는 실제 콘텐츠를 놓치는 경계 상자를 보고할 수 있기 때문이다. 이전 예제의 루프를 같은 States 배열에 걸쳐 확장하면 이 필드들을 직접 읽는다

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
    States[I].FormBBoxKnown then
    Writeln('Form ', States[I].XObjectResource, ' matrix ',
      States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
      ' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);

같은 리소스 이름을 가진 두 Form은 폰트 하나를 공유하는가?

아니다. /F1 같은 리소스 이름은 그것이 사용되는 지점에 활성화된 리소스 딕셔너리에 상대적으로만 의미가 있으며, 두 개의 서로 다른 Form XObject는 그 동일한 이름 아래 완전히 다른 두 폰트를 자유롭게 정의할 수 있다. PDFlibPas는 모든 리소스 이름과 함께 리소스 스코프를 추적함으로써 이를 해결한다: Form이 자신만의 /Resources 딕셔너리를 가지고 있으면, 그 딕셔너리는 그 안의 모든 것을 위한 완전한 리소스 스코프가 되며, Form 자체의 딕셔너리가 우연히 생략한 것에 대해 페이지나 호출자 딕셔너리로 키별 폴백은 없다. /Resources 키가 전혀 없는 Form만이 — 일부 구형 PDF 생성기가 여전히 만들어내는 패턴 — 호출하는 딕셔너리를 통째로 상속하며, 이는 새 출력물에서 의존할 가치가 있는 일반 규칙이라기보다 의도적인 호환성 예외다. 그래서 TPDFlibContentGraphicsState 스냅샷 안의 폰트 정체성은 이름 하나가 아니라 FontResourceFontResourceScope의 쌍이며, FontObjectNumber는 그 특정 스코프에서 주어진 /F1이 정확히 어느 간접 객체로 해석되었는지 확인하기 위해 제공된다

같은 스코핑은 Form이 가질 수 있는 다른 모든 이름 붙은 리소스에도 적용되며, ExtGState 항목과 중첩된 XObject 항목도 포함된다. 밑에 있는 해석 메커니즘이 폰트를 특별 취급하지 않기 때문이다 — 폰트 경우가 그저 가장 중요한 경우일 뿐인데, 정체성이 어긋난 폰트는 명백한 실패 대신 조용히 잘못된 글리프를 만들어내기 때문이다. 리소스 스코프별로 그룹화하지 않고 오직 폰트 이름만으로 텍스트 런을 그룹화하는 추출 코드는 우연히 이름을 공유하는 시각적으로 다른 두 폰트를 병합할 것이며, 그 실수는 누군가 원래 하나의 일관된 폰트로 읽혀야 했던 것 안에 잘못된 서체의 숫자가 앉아 있는 것을 알아차릴 때까지 드러나지 않을 것이다

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
    RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
      States[I].FontObjectNumber, States[I].ContentDepth);

여러분 자신의 파이프라인에서 FormTraversalStatus 읽기

FormTraversalStatus는 모든 Do 스냅샷을 그 자체로 작은 진단 보고서로 바꾸며, 이를 무시하는 파이프라인은 불완전한 추출을 설명해 줄 정보를 정확히 버리고 있는 것이다. ftsNotApplicable은 그 명령이 애초에 해석된 Form 호출이 전혀 아니었다는 뜻이다; ftsNotRequested는 이 호출에 대해 재귀가 꺼져 있었다는 뜻이다; ftsEnumerated는 Form이 성공적으로 파싱되고 순회되었다는 뜻이다; ftsDepthLimitftsCycle은 하강이 의도적으로 중단되는 두 가지 방식을 표시한다; ftsMalformed는 순회를 멈춘 그 외의 모든 것을 다룬다 — 해석할 수 없는 스트림 참조, 파싱에 실패한 /Matrix/BBox, 또는 Form 자신의 콘텐츠를 실행하는 도중 발생한 예외다. 마지막 경우는 실무적으로 중요한데, 실패한 중첩 순회는 그 가지에 대해 이미 만들어낸 부분적인 출력을 무엇이든 롤백하므로, 호출자는 Form이 정말로 비어 있었는지 아니면 그저 자신의 콘텐츠 스트림 두 명령 만에 터져버렸는지 추측할 필요가 전혀 없다

var
  Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
  Status: TPDFlibContentFormTraversalStatus;
begin
  for Status:= Low(Tally) to High(Tally) do
    Tally[Status]:= 0;
  for I:= 0 to Count- 1 do
    Inc(Tally[States[I].FormTraversalStatus]);
  if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
    FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;

경계, 비용, 그리고 이것이 자리하는 곳

Form의 콘텐츠 스트림은 그 Form이 몇 번 호출되든 열거 호출당 정확히 한 번만 디코딩되고 파싱되는데, PDFlibPas가 형제 호출마다 다시 파싱하는 대신 밑에 있는 스트림 객체에 대해 파싱된 명령 목록을 캐시하기 때문이다 — 도입부 예제의 세 모서리 스탬프는 한 번 디코딩되고 세 번 순회될 뿐, 세 번 디코딩되지 않는다. 매 호출마다 다시 만들어지는 것은 한 호출 지점과 다음 것 사이에서 합법적으로 달라지는 모든 것이다: 자식 트래커, 연결된 CTM, 교차된 클립, 리소스 스코프다. 이 호출당 CTM과 클립 회계는 PDFlibPas의 콘텐츠 스트림 CTM과 클리핑 상태 트래커 뒤에 있는 것과 같은 메커니즘이며, Form 재귀 자체를 넘어서는 어떤 콘텐츠 스트림 순회에도 이와 함께 읽어볼 가치가 있다

이 API를 더 큰 파이프라인에 넣기 전에 기대치를 설정해 둘 가치가 있는 제한 두 가지가 있다. 64단계 깊이 상한은 정말로 깊은 문서를 위한 튜닝 손잡이가 아닌데, 실제 인보이스, 명세서, 보고서 템플릿은 사실상 Form을 3~4단계보다 더 깊이 중첩하는 일이 없기 때문이다 — 실제로 ftsDepthLimit에 도달하는 문서는 유난히 정교하다기보다 잘못된 형식이거나 적대적일 가능성이 훨씬 크며, 더 큰 숫자로 조용히 재시도하는 대신 데이터 품질 신호로 로그에 남길 가치가 있다. EnumPageContentStatesEx는 또한 읽기 쪽 분석 API다: 이는 콘텐츠 스트림이 무엇을 하는지 보고할 뿐, Form이 애초에 보여야 하는지는 보고하지 않는다. 이는 스탬프나 워터마크 Form이 뷰어가 꺼두었을지 모르는 레이어 뒤에 있을 때 Optional Content Group 가시성 상태가 답하는 별개의 질문이다. 호출 체인 순환 감지, 호출별 격리, 리소스 스코핑은 함께 Delphi와 C++Builder용 PDFlibPas 컴포넌트의 콘텐츠 스트림 검사 표면의 한 축을 이룬다