기술 문서

PDFlibPas의 콘텐츠 스트림 상태: CTM과 클립 추적

Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트 라이브러리인 PDFlibPas는 렌더링 캔버스를 전혀 건드리지 않고 TPDFContentStateTracker 클래스를 통해 페이지의 콘텐츠 스트림을 재생한다. 파싱된 오퍼레이터를 한 번에 하나씩 트래커에 먹이면, 현재 변환 행렬, 텍스트 행렬, 클립 경계, q/Q 저장 스택으로 이루어진 진행 중인 그래픽 상태 기록이 유지되어, 모든 오퍼레이터가 실행되기 전이나 후에 스냅샷을 찍을 수 있다

텍스트 한 줄이 실제로 인쇄된 페이지의 어디에 놓이는지 물어보면, 콘텐츠 스트림의 원시 숫자만으로는 매번 오도된다. TPDFContentProgram.GetTextRuns는 이미 TPDFTextRunOriginXOriginY 필드로 각 텍스트 표시 명령의 앵커 지점을 보고하며, 필드 주석은 이 지점이 이미 Tm, Td, TD, T*를 거쳐 접혀 들어간 텍스트 공간에 있다고 명확히 밝힌다. 여전히 빠져 있는 것, 그리고 그 주석이 호출자가 직접 공급해야 한다고 말하는 것은, 그 정확한 명령 시점에 활성화된 CTM이다 — 스트림 안에서 그 시점까지 열려 있는 몇 개의 q/Q 쌍 안에 중첩된, 지금까지 연결된 모든 cm의 곱이다

콘텐츠 스트림을 렌더링하는 대신 재생하는 이유는?

PDFlibPas는 두 가지 서로 다른 작업을 위해 두 가지 별개의 그래픽 상태 개념을 유지하며, 이 분리는 의도적이다. 렌더러의 내부 상태 레코드는 살아있는 디바이스 캔버스 핸들, 클리핑 영역 핸들, 폰트 래스터화 캐시를 담는다 — 현재 그려지고 있는 어떤 서피스에든 묶인 실제 리소스이며, 그 서피스가 사라지면 의미가 없어진다. TPDFContentGraphicsState는 그 무엇도 담지 않는다: 이는 ISO 32000-1 §8.4가 콘텐츠 스트림 오퍼레이터만으로 도달 가능하다고 정의하는 값들 — CTM, 선 스타일, 색상, 텍스트 상태, 파생된 클립과 경로 경계 — 로 제한된 순수한 레코드다. 이 레코드가 캔버스 참조도 열린 파일 핸들도 담지 않기 때문에, 호출자는 콘텐츠 스트림을 파싱하고 TPDFContentStateTracker로 순회한 뒤, 그 바이트를 만들어낸 것이 사라진 지 한참 지나서도 결과 스냅샷을 계속 사용할 수 있다

TPDFContentStateTracker는 CTM을 어떻게 만드는가

TPDFContentStateTracker.Apply는 PDF 자체가 명시하는 것과 같은 선행 곱셈으로 cm 오퍼레이터의 여섯 피연산자를 트래커의 CTM에 연결한다: 새 행렬 M2는 점이 P′ = P × M(ISO 32000-1 §8.4)으로 변환되는 행 벡터 관례에서 M2 × CTM으로 현재 CTM과 결합된다. 잘못되기 쉬운 부분은 선형 부분이 아니라 이동항에 있다: M2 자체의 이동은 현재 CTM의 이동이 위에 더해지기 전에 현재 CTM의 회전-확대 성분을 거쳐야 한다. 이 단계를 건너뛰고 순진한 성분별 결합을 하드코딩하면, 첫 번째 단독 cm 테스트는 올바르게 보이면서도 두 번째나 세 번째 중첩된 cm 아래의 모든 좌표는 조용히 어긋난다. 이는 정확히 코드 리뷰를 살아남는 종류의 버그인데, 이를 잡아낼 유닛 테스트가 실패하려면 최소한 두 개의 연결된 변환이 필요하기 때문이다

var
  Prog: TPDFContentProgram;
  Runs: TPDFTextRunArray;
  States: TPDFContentGraphicsStateArray;
  DeviceX, DeviceY: Double;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  try
    Prog.Parse(ContentBytes);
    Runs := Prog.GetTextRuns;
    // One before-instruction snapshot per operator, computed in a single pass
    States := Prog.TraceGraphicsStates(nil, False);
    for I := 0 to High(Runs) do
    begin
      // OriginX/OriginY already fold in Tm/Td/TD/T*; only the CTM active
      // at this instruction is still missing (ISO 32000-1 8.4)
      with States[Runs[I].InstructionIndex].CTM do
      begin
        DeviceX := Runs[I].OriginX * M11 + Runs[I].OriginY * M21 + DX;
        DeviceY := Runs[I].OriginX * M12 + Runs[I].OriginY * M22 + DY;
      end;
      LogTextOrigin(Runs[I].Text, DeviceX, DeviceY); // caller-supplied handler
    end;
  finally
    Prog.Free;
  end;
end;

위 루프는 도입부의 고민에 답한다: TPDFContentProgram.GetTextRuns는 이미 Tm, Td, TD, T*를 거쳐 접힌 OriginXOriginY를 돌려주고, TraceGraphicsStates(nil, False)는 남은 마지막 조각, 즉 각 런이 캡처된 정확한 인덱스에서의 명령 실행 전 CTM을 전체 프로그램에 대한 단일 선형 패스로 공급한다. nil을 넘기면 그 메서드가 이 호출을 위한 비공개 트래커를 소유하고 내부적으로 해제하게 되는데, 이는 일회성 스캔에 올바른 선택이다; 대신 기존 TPDFContentStateTracker 인스턴스를 넘기는 것이 하나 이상의 콘텐츠 스트림으로 조립된 페이지 전체에 걸쳐 상태를 연속적으로 유지하는 방법인데, ISO 32000-1이 페이지의 /Contents 배열을 하나의 논리적 스트림으로 취급하며 q/Q 스택도 그에 맞아야 하기 때문이다

텍스트 행렬은 Q를 살아남고, 그래픽 상태는 그렇지 않다

ISO 32000-1 §9.4.2는 Td, TD, Tm, T*를 BT/ET 블록 안에서 텍스트 행렬과 텍스트 행 행렬을 만드는 오퍼레이터로 정의하며, PDFlibPas는 그 구분을 날카롭게 유지한다: Td와 TD는 순수한 이동을 텍스트 행 행렬에 연결하고, T*는 현재 리딩값의 음수를 사용해 같은 일을 하며, Tm만이 주어진 여섯 숫자로 두 행렬 모두를 완전히 대체한다. BT는 텍스트 객체 시작 시점에 정확히 한 번 두 행렬을 항등원으로 초기화한다 — 하지만 q와 Q는 이들을 전혀 건드리지 않는다. TPDFContentStateTracker.Apply는 정확히 이 이유로 coRestoreState를 특별 취급한다: 저장된 상태를 스택에서 꺼내기 전에 현재 텍스트 행렬, 텍스트 행 행렬, BT/ET 플래그를 캡처해 두고, 꺼낸 상태가 무엇을 담고 있었든 그 위에 이를 재적용한다. 텍스트 런을 감싼 q/Q 쌍이 텍스트 위치를 뒤로 옮기는 것은 아니기 때문이다

var
  Tracker: TPDFContentStateTracker;
  Prog: TPDFContentProgram;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  Tracker := TPDFContentStateTracker.Create;
  try
    Prog.Parse('BT 100 700 Td q 2 0 0 2 0 0 cm (A) Tj Q (B) Tj ET');
    for I := 0 to Prog.Count - 1 do
    begin
      Tracker.Apply(Prog[I]);
      if Prog[I].Op in [coShowText, coRestoreState] then
        LogState(Prog[I].OpName, Tracker.Snapshot); // caller-supplied handler
    end;
  finally
    Tracker.Free;
    Prog.Free;
  end;
end;

이 시퀀스를 실행하면 두 번째 Tj에서 보고되는 CTM은 q 이전에 있던 항등 배율로 돌아가 있다 — 저장/복원 쌍 안의 2 0 0 2 0 0 cm은 q/Q가 요구하는 대로 사라져 있다. 하지만 같은 명령에서의 TextMatrix.DX는 여전히 100이다: 그것을 설정한 Td는 q보다 먼저 실행되었으므로, 이는 Q가 애초에 건드릴 자격이 있었던 그래픽 상태가 아니다. 그렇지 않다고 가정한 도구는 두 번째 글리프 런이 페이지의 잘못된 수평 위치에서 시작한다고 보고했을 것이다

클리핑 경로 오퍼레이터가 실행되면 무슨 일이 일어나는가?

W나 W* 오퍼레이터는 클립을 즉시 줄이지 않는다; 이는 그저 어떤 채우기 규칙을 쓸지만 기록하고, 실제 교집합 연산은 그 뒤를 따르는 경로 그리기 오퍼레이터를 기다린다. PDF 작성자들이 아무것도 그리지 않으면서 클립만 하기 위해 흔히 사용하는 무동작 그리기 오퍼레이터 n도 포함된다. TPDFContentStateTracker는 그 2단계 타이밍을 정확히 그대로 따라 한다: coClipcoClipEvenOdd는 그저 대기 중인 클립 규칙 플래그만 설정하고, 모든 경로 그리기 오퍼레이터가 호출하는 EndCurrentPath가 실제로 대기 중인 경로의 경계를 ClipMinX, ClipMinY, ClipMaxX, ClipMaxY에 교차시킨다. 이 단계를 올바르게 처리하는 것은 전후 스냅샷 계약 자체에 중요하다: W 명령에서 정확히 찍힌 사전 스냅샷은 여전히 이전의 더 넓은 클립을 보여줘야 하는데, 그 지점에서 클립은 아직 효력을 발휘하지 않았기 때문이며, 이 두 단계를 하나로 뭉갠다면 사전 상태가 자기 이름대로의 의미를 갖는다고 의존하는 모든 호출자를 조용히 깨뜨릴 것이다

ClipBoundsExact는 호출자가 두 상황 중 무엇을 보고 있는지 알려주며, 이는 오직 그 외에는 비어 있는 경로 위에 re가 만든 단일 축 정렬 사각형에 대해서만 True다 — PDFlibPas가 네 개의 숫자로 정확히 표현할 수 있는 유일한 형태다. 그 외의 모든 것 — 회전된 사각형, 곡선 윤곽, 여러 하위 경로를 가진 복합 경로, 또는 텍스트 렌더링 모드로 만들어진 클립 — 은 여전히 ClipMinX부터 ClipMaxY까지를 만들어내지만 ClipBoundsExact는 False로 지워지는데, 이 네 숫자가 안전한 외곽 경계일 뿐 진짜 클립 형태가 아니라는 정직한 신호다. PDF 페이지를 1비트 흑백으로 렌더링하기에서 설명하는 GDI 하프톤 다운 변환 전에 사각형 하위 영역을 분리하는 것처럼 그 경계만 필요한 호출자는 페이지 지오메트리로부터 다시 도출하는 대신 이를 직접 읽을 수 있다

베지어 곡선: 정확한 경계인가 안전한 경계인가

3차 베지어 세그먼트의 경계를 구하는 가장 저렴한 방법은 그 네 제어점의 볼록 껍질을 취하는 것이며, 곡선이 그것을 벗어나는 일이 없으므로 이는 항상 안전하다 — 하지만 얕고 넓은 곡선은 실제로 차지하는 것보다 훨씬 큰 경계 상자를 보고할 수 있고, 이는 정확히 가장 중요한 순간, 즉 크고 장식적인 경로에서 클립 기반 필터링을 약화시킨다. PDFlibPas는 대신 더 엄밀한 문제를 해결한다: 각 축에 대해, 열린 구간 (0, 1) 안에서 3차 곡선 도함수의 근을 구하고 찾은 근들과 양 끝점 모두에서 곡선을 평가하는데, 이는 과대추정 대신 곡선의 진짜 축 정렬 범위를 얻는 표준적인 닫힌 형식 방법이다. 하지만 곡선별 정밀도가 클립 자체로 이어지지는 않는다: 곡선 윤곽이 클리핑 경로가 되는 순간, ClipBoundsExact는 그것에 대해 여전히 False로 떨어지는데, 경계 상자가 아무리 엄밀해도 그것이 경계 짓는 곡선과 같은 형태는 아니기 때문이다. 상태 트래커는 곡선이 실제로 있는 곳에 사각형이 있다고 호출자가 가정하도록 두는 대신 그렇게 말하는 쪽을 택한다

각 오퍼레이터 전후의 상태 읽기

호출자가 사전 상태를 원하는지 사후 상태를 원하는지는 전적으로 그 오퍼레이터가 무엇을 하는지에 달려 있다: 경로나 텍스트 런에 대한 그리기나 히트 테스트 질문은 그 오퍼레이터가 실제로 어떻게 그렸는지를 결정한 것이기 때문에 그 오퍼레이터가 실행되기 직전의 상태를 원하는 반면, gs 같은 상태 설정 오퍼레이터에 대한 진단 질문은 보통 그것이 방금 무엇을 바꿨는지 보고 싶어 한다. TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction)는 그 선택을 정확히 하나의 불리언으로 노출하며, 어느 순간이 요청되었든 상관없이 전체 프로그램에 대한 하나의 선형 패스로 명령마다 하나의 TPDFContentGraphicsState를 계산한다. GetGraphicsState(InstructionIndex, AfterInstruction, State)는 전체 프로그램이 아니라 단일 명령에 대해 같은 전후 선택을 제공하지만, 매 호출마다 인덱스 0부터 다시 재생해 그곳에 도달하므로, 루프 안에서 이를 호출해 여러 인덱스를 스캔하는 것은 같은 프로그램에 대한 단일 O(n) 호출인 TraceGraphicsStates에 비해 O(n²)의 비용을 치른다

var
  Before, After: TPDFContentGraphicsState;
begin
  // Same instruction index, two different instants: before vs. after it runs
  Prog.GetGraphicsState(CmIndex, False, Before);
  Prog.GetGraphicsState(CmIndex, True, After);
  // Before.CTM reflects every earlier cm; After.CTM already folds in
  // this instruction's own concatenation as well
end;

잘못된 형식의 콘텐츠 스트림과 함께 살아가기

실제 PDF 생성기에서 흔할 만큼 흔한 잘못된 형식의 입력 두 종류가 있어서, TPDFContentStateTracker는 이들에 실패하는 대신 견뎌내야 한다. 첫 번째는 q/Q 경계를 가로지르는 경로다: 현재 경로, 현재 점, 하위 경로 개수는 그래픽 상태 매개변수가 아니다 — ISO 32000-1 §8.4는 q와 Q가 저장하고 복원하는 것을 다루는데, 구성 중인 현재 경로는 거기 포함되지 않는다 — 그래서 TPDFContentStateTracker는 그 데이터를 저장된 상태 완전히 바깥에서 추적하며, q 이전에 시작된 하위 경로는 짝을 이루는 Q 직후에도 여전히 그려지지 않은 채로 남아 있다. 두 번째는 스트림 안에서 그 이전에 짝이 되는 q가 전혀 없는 맨 Q인데, 콘텐츠 스트림 조각을 연결로 조립하며 회계를 잘못 처리하는 생성기의 출력물에서 드물지 않다. TPDFContentStateTracker.RestoreUnderflowCount는 예외를 일으키거나 상태를 손상시키는 대신 이런 사건 각각을 센다: 짝 없는 Q는 마치 그 명령이 무동작이었던 것처럼 현재 그래픽 상태를 정확히 그대로 남겨두므로, 스트림의 나머지는 정상적인 상태 위에서 계속 재생되고, 호출자는 그 개수로부터 나중에라도 그 입력을 만든 쪽에 문제를 알릴 가치가 있는지 결정할 수 있다

CTM 합성, q/Q로부터의 텍스트 행렬의 독립성, 클리핑 경로의 단계적 실현은 콘텐츠 스트림이 언제 혹은 그려지기라도 하는지에 전혀 의존하지 않으며, 바로 이것이 핵심이다: 같은 TPDFContentStateTracker 스냅샷은 페이지가 전혀 렌더링되지 않든, PDFlibPas의 다중 엔진 PDF 렌더링 가이드에서 다루는 런타임 엔진 전환을 포함해 그 파일을 위해 PDFlibPas가 선택하는 어떤 백엔드에 곧 넘겨지든 정확하다. 콘텐츠 분석, 좌표 매핑, 편집 도구 모두 렌더러에게 개입해 달라고 요청하기 훨씬 전이나 그런 요청을 전혀 하지 않고도 트래커의 출력만으로 완전히 실행될 수 있다

TPDFContentStateTracker를 통한 콘텐츠 스트림 재생은 Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트 라이브러리인 PDFlibPas에 내장된 구조화된 콘텐츠 편집 프레임워크의 일부다