기술 문서

Delphi 렌더러의 PDF 텍스트 어드밴스와 q/Q 클립 복원

/F1 1 Tf를 설정하고, 12 0 0 12 72 700 Tm를 두고, (Hello world) Tj로 보여주는 콘텐츠 스트림은 완전히 합법입니다. 손으로 구운 렌더러는 십일 글자 전부를 5.5포인트 안에 그려 버리는 경우가 많습니다. 66포인트 단어가 있어야 할 왼쪽 여백의 검은 얼룩으로요 — 그 얼룩 뒤의 산술은 한 줄짜리입니다. ISO 32000-1 §9.4.4는 각 글리프 어드밴스를 텍스트 공간에서 정의하므로 변위는 페이지에 닿기 전에 텍스트 행렬을 통과해야 하고, q ... Q 안에서 설정된 클립은 선 폭만큼이나 그래픽 상태의 소유입니다

PDF가 Tf 1을 쓰면 텍스트가 뭉치는 이유는?

텍스트가 뭉치는 이유는 렌더러가 텍스트 공간 거리를 Tm의 이동 성분에 곧장 더했기 때문입니다. 이것은 텍스트 공간과 유저 공간이 같은 스케일을 공유하는 동안에만 통합니다. 실제 세상 생산자 중 상당수는 폰트 크기를 1로 쓰고 진짜 크기를 텍스트 행렬에 실습니다. /F1 1 Tf와 12 0 0 12 72 700 Tm에서 500유닛 너비의 글리프는 텍스트 공간에서 0.5만큼 전진하는데, Tm이 그것을 페이지 위의 6포인트로 스케일합니다. Tm.e := Tm.e + Adv를 실행하는 렌더러는 펜을 0.5포인트 옮깁니다. 모든 글리프가 이전 글리프에서 12분의 1 문자만큼 착지하고, §9.4.4를 따르는 뷰어는 같은 파일을 평범한 본문 텍스트로 보여줍니다

HotPDF 렌더러에서 Tf 1 아래 텍스트가 뭉개지는 이유: 12 0 0 12 72 700 Tm에서 500유닛 글리프는 텍스트 공간 0.5유닛을 전진해야 하고 Tm이 그것을 6포인트로 스케일하지만, 0.5를 Tm.e에 곧장 더하면 본문 한 줄이 글리프당 12분의 1 문자 얼룩으로 렌더링됩니다
폰트 크기를 텍스트 행렬에 인코딩하는 생산자는 Tm.e 지름길을 즉시 폭로합니다. 모든 글리프가 이전 글리프에서 12분의 1 문자만큼 착지하며, 진짜 크기를 Tf에 쓴 출력에서는 결코 드러나지 않는 부분입니다

이 지름길이 오래 살아남는 이유는 사람들이 테스트하는 대부분의 생성기가 /F1 12 Tf와 스케일 없는 Tm을 쓰기 때문이고, 그 경우에는 텍스트 공간과 유저 공간이 실제로 같은 크기입니다. 고장을 내는 콘텐츠 스트림을 맨 위 주석으로 단, 순진한 버전 전체가 다음과 같습니다:

// 크기를 Tf가 아니라 Tm에 인코딩하는 생산자의 콘텐츠 스트림:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// 순진한 어드밴스: 텍스트 공간 거리를 유저 공간인 양 Tm.e에 더함
Adv := W * FontSize / 1000;                  // 500유닛 글리프에 0.5
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // 너비에만 Th
Adv := Adv + CharSpace;                      // Tc는 Th로 스케일되지 않음
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw가 Tfs로 잘못 스케일됨
Tm.e := Tm.e + Adv;                          // Tm.a, Tm.b, Tm.c, Tm.d 무시

// 순진한 TJ 조정: Th 없음, 또다시 Tm.e만
Tm.e := Tm.e - NumValue * FontSize / 1000;

Tm.e 줄이 요란한 버그입니다. 나머지는 더 조용하고 다른 파일에서 드러납니다. 워드 스페이싱 Tw는 이미 스케일되지 않은 텍스트 공간 유닛으로 표현되므로, FontSize / 1000을 곱하면 Tf 12 아래의 3 Tw가 0.036유닛이 됩니다. 양쪽 정렬된 문단은 단어 사이 간격을 거의 다 잃습니다. 수평 스케일링 Th는 Tc와 Tw에도, TJ 커닝 숫자에도 적용되어야 합니다. Code = 32 검사는 2바이트 코드 안의 값 32 바이트에도 발동해 CJK 텍스트에 무작위 간격을 뿌립니다. 이런 텍스트 상태 실수는 요란하게 실패하는 일이 드뭅니다. 글리프 하나 그리기 전에 Tc, Tw, Tz를 0으로 만들 수 있는 operand 인덱스와 리소스 이름 함정처럼, 한 생산자의 파일에서는 그럴듯한 페이지를, 다음 생산자의 파일에서는 깨진 페이지를 만들어 냅니다

ISO 32000-1 §9.4.4는 글리프 어드밴스를 어떻게 정의할까?

ISO 32000-1 §9.4.4는 어드밴스를 전적으로 텍스트 공간에서 계산해 이동 행렬로서 텍스트 행렬에 적용합니다. 스케일링, 회전, 스큐는 전부 Tm에서 나오고 어드밴스 자체에서는 나오지 않습니다. 수평 쓰기에서 tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th이며, w0는 em의 천분율 글리프 너비, Tj는 TJ 조정, Th는 100으로 나눈 Tz입니다. 새 행렬은 [1 0 0 1 tx 0] × Tm입니다. 그 곱을 펼치면 e에는 a·tx가, f에는 b·tx가 더해지는데, 그래서 a가 1이고 b가 0이 아닌 한 텍스트 공간의 이동이 e에 대한 단순 덧셈일 수는 없습니다. §9.3.3에 따라 Tw는 단일 바이트 문자 코드 32에만 적용됩니다

HotPDF 렌더러의 ISO 32000-1 9.4.4 글리프 어드밴스: tx를 텍스트 공간에서 w0, Tj, Tfs, Tc, Tw, Th로 계산한 다음 텍스트 행렬의 a, b, c, d 계수를 통해 적용해 Td, TD, TJ와 보이지 않는 텍스트가 모두 같은 규칙으로 움직입니다
어드밴스를 Tm.e에 곧장 더하는 것은 텍스트 공간이 유저 공간과 같을 때만 동작합니다. 행렬 계수로 우회시켜야 스케일되고, 회전하고, 스큐된 텍스트가 올바릅니다

HotPDF Delphi Component는 이 공식을 한 곳에 둡니다. 유닛 HPDFContentStream은 글리프별 어드밴스용 HPDFTextGlyphDisplacement와 TJ 숫자용 HPDFTextAdjustmentDisplacement를 내보내고, 페이지 렌더러와 텍스트 추출 인터프리터가 둘 다 호출하므로 올바르게 렌더링되는 페이지는 보이는 것과 맞는 글리프 위치를 보고합니다. 라이브러리 파서 위에 자기만의 콘텐츠 스트림 소비자를 쓴다면 같은 두 루틴을 호출하고 행렬 단계는 스스로 공급할 수 있습니다:

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement

type
  // 자작 텍스트 행렬 레코드, HotPDF 타입 아님
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// 자작 헬퍼: [1 0 0 1 Tx Ty] x Tm, a, b, c, d를 통해 이동
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
  M.e := M.e + M.a * Tx + M.c * Ty;
  M.f := M.f + M.b * Tx + M.d * Ty;
end;

// W0는 1/1000 em 단위 글리프 너비; Tz는 Tz 피연산자 (100 = 스케일 없음)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
  TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
    (Code = 32) and not TwoByteCodes,  // Tw는 단일 바이트 공백에만
    Tc, Tw, Tz, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);
  // Tf 1, Tm 12 0 0 12 72 700, W0 = 500이면: Tx = 0.5, Tm.e는 6만큼 증가
end;

procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);  // Th는 커닝에도 적용
end;

렌더러 안에서는 같은 행렬 단계가 텍스트 위치를 움직이는 모든 연산자를 구동합니다. Td, TD, T*, ', "는 라인 행렬을 계수를 통해 이동시키므로, 12배 텍스트 행렬 아래의 0 -1.2 Td는 베이스라인을 1.2가 아니라 14.4포인트 내립니다. 칠해지지 않는 글리프도 동일한 경로를 갑니다. OCR 텍스트 레이어가 쓰는 렌더 모드 3, 즉 보이지 않는 모드의 텍스트와 숨겨진 optional content 안의 텍스트도 칠해진 텍스트와 똑같이 Tm을 전진시킵니다. 여기에 전용 산술 사본을 주는 것이 고전적인 실수인데, 그러면 같은 BT 블록에서 보이지 않는 런 뒤에 오는 보이는 텍스트가 살짝 다른 위치에서 시작하게 됩니다

글리프 자체의 배치

글리프 배치도 같은 논리를 따릅니다. 임베드된 아웃라인이 없어 렌더러가 GDI TextOutW로 폴백할 때는 Th를 포함해 CTM × Tm × rise × em 스케일로 전체 글리프 행렬을 만듭니다. 행렬은 SaveDC / RestoreDC 쌍 안에서 GM_ADVANCED 모드의 SetWorldTransform으로 들어갑니다. GDI 폰트는 고정 1000유닛 높이로 만들고 크기 잡기는 월드 변환이 맡습니다. 그래서 회전하고 스큐된 텍스트는 변환된 원점에서 똑바로 그려지는 대신 자기 방향을 유지합니다. 스트로크 렌더 모드는 스트로크 전에 월드 변환을 리셋하는데, 1000유닛 설계 크기 기준으로 잰 펜 폭은 아니면 거의 0으로 줄어들기 때문입니다. 수직 쓰기만이 의도된 비대칭입니다. WMode 1 폰트는 수직 메트릭만큼 y 축을 따라 내려가고, Tc와 Tw는 그 축을 따라 적용되며, Th는 전혀 적용되지 않습니다

q/Q는 PDF 그래픽 상태에서 실제로 무엇을 저장할까?

ISO 32000-1 §8.4.2는 현재 클리핑 경로를 그래픽 상태의 일부로 나열하므로, Q는 짝이 되는 q 시점 그대로 클립을 복원해야 합니다. 당연하게 들립니다. GDI 기반 렌더러에서는 놓치기 쉽습니다. CTM, 색, 라인 파라미터, 텍스트 상태는 렌더러 자신의 상태 스택에 살지만 클립은 그 스택 밖인 디바이스 컨텍스트에 살기 때문입니다 — 수치 상태를 복사하면 클립만 빼고 모든 것이 복원됩니다. 그러면 q ... Q 블록 안의 W n이 페이지의 이후 모든 연산을 계속 잘라 내고, 증상은 원인 연산자와 동떨어진 곳의 사라진 콘텐츠처럼 보입니다

Form XObject가 같은 실패로 가는 두 번째 경로를 엽니다. §8.10은 폼에 자기 콘텐츠를 둘러싼 암묵적 save와 restore를 주고 /BBox로 클립하는데, 실제 폼 콘텐츠는 스펙이 짝을 요구함에도 자기 q와 Q를 불균형으로 남겨 두기도 합니다. HotPDF는 폼을 돌리기 전에 클립을 기록하고 디바이스 컨텍스트를 저장하며, 폼이 끝나면 폼이 열어 둔 클립 프레임을 버리고 디바이스 컨텍스트를 복원해 저장된 각 영역이 정확히 한 번 해제되게 합니다. 호출자의 상태를 튕겨 낼 폼 안의 길 잃은 Q는 적용되는 대신 거부됩니다. RenderFallbackPolicy가 rfpReport이면 그 거부가 진단에 나타나므로, 렌더링 문제와 허술한 생산자를 구별하기 쉽습니다:

// 진단: THPDFRenderFallbackDiagnostics (HPDFPageRenderer에 선언)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
  Diags := Pdf.GetLastRenderFallbackDiagnostics;
  for J := 0 to High(Diags) do
    if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
      Log.Add(Format('page %d: unbalanced Q refused - %s',
        [PageIndex + 1, string(Diags[J].Reason)]));
finally
  Bmp.Free;  // nil에 대한 Free는 안전
end;

프레임이 클립을 바꿀 때만 클립 캡처하기

직진 해법은 q마다 GDI 클립 영역을 읽고 Q마다 되돌려 놓는 것입니다. 맞는 방법입니다. 그리고 q를 가장 많이 쓰는 바로 그 페이지들에서 비쌉니다. 생성기들은 원점을 옮기려고 각각의 작은 그리기를 q 1 0 0 1 x y cm ... Q로 감싸는 것을 좋아하기 때문입니다. 그 쌍 하나하나가 GDI 영역을 만들고 클립을 복사했다가 다시 지우게 됩니다. 여러 페이지가 병렬로 렌더링되면 스레드들은 래스터라이징 대신 GDI 영역 객체를 두고 경쟁하고, 병렬 렌더 파이프라인의 가속은 그만큼 줄어듭니다

HotPDF 렌더러의 지연 GDI 클립 캡처: q는 스택 깊이와 대상 표면만 기록하고, 클립 영역은 W, n, 텍스트 클립, 폼 진입이 클리핑을 바꾸기 직전에 읽히며, Q가 복원하고 해제합니다. q cm Q 쌍으로 가득한 페이지는 GDI 영역을 전혀 할당하지 않습니다
q마다 GDI 영역을 만들면 렌더 스레드가 영역 객체를 두고 경쟁합니다. 그래서 클립은 연산자가 클리핑을 바꾸려 할 때만 캡처됩니다

HotPDF는 비싼 부분을 미룹니다. q는 스택 깊이와 자기가 속한 표면만 기록하는데, 프레임 배열은 16부터 두 배씩 자라고 페이지 전체에서 재사용되므로, 수치 변환의 나열은 GDI 객체를 전혀 할당하지 않습니다. 클립은 그 프레임의 무언가가 클립을 바꾸려 하는 첫 순간에 캡처됩니다. pending인 W나 W*를 동반한 경로 페인팅 또는 n, 패턴 채움, 텍스트 렌더 모드 4~7이 남기는 클립, 폼 진입이 그 시점입니다. Q에서는 캡처된 프레임이 영역을 되돌려 놓고 해제하며, 클립을 건드리지 않은 프레임은 수치 상태만 팝합니다. 평범한 페이지는 영역을 전혀 할당하지 않고, 클립하는 페이지는 실제 클립 변경당 정확히 하나의 영역을 지불합니다. PDF 인터프리터용 자작 GDI 디바이스를 만든다면 같은 형태가 수십 줄에 들어갑니다:

// 자작 GDI 기반 디바이스를 위한 스케치; HotPDF API가 아님
// uses 절에 Windows, SysUtils, Math 필요
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q: GDI 호출 전혀 없음
begin
  if FCount = Length(FFrames) then
    SetLength(FFrames, Max(16, Length(FFrames) * 2));
  FFrames[FCount].Region := 0;
  FFrames[FCount].Captured := False;
  Inc(FCount);
end;

procedure TMyDevice.BeforeClipChange;     // W n, W f, 텍스트 클립, 폼 BBox 전에 호출
var
  R: HRGN;
  Res: Integer;
begin
  if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
  R := CreateRectRgn(0, 0, 0, 0);
  if R = 0 then RaiseLastOSError;
  Res := GetClipRgn(FDC, R);              // 1 복사됨, 0 클립 없음, -1 오류
  if Res <= 0 then
  begin
    DeleteObject(R);
    R := 0;
    if Res < 0 then RaiseLastOSError;
  end;
  FFrames[FCount - 1].Region := R;
  FFrames[FCount - 1].Captured := True;
end;

procedure TMyDevice.PopState;             // Q
begin
  if FCount = 0 then Exit;                // 불균형 Q: 무시
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0은 클립을 제거
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

그 스케치의 두 세부가 대부분의 무게를 진다. GetClipRgn이 0을 반환하는 것은 오류가 아닙니다. 그 시점에 디바이스 컨텍스트에 클립이 없었다는 뜻입니다. Q에서 SelectClipRgn에 0 영역을 넘기는 것이 바로 짝이 되는 q에 존재하지 않던 클립을 제거하는 방법입니다. 0을 실패로 치면 안쪽 클립이 제자리에 남습니다. 다른 세부는 Captured 플래그로, 같은 프레임의 두 번째 클립 변경이 이미 좁아진 영역으로 저장된 영역을 덮어쓰지 않게 막습니다

저장된 클립과 어드밴스 규칙이 멈추는 곳

저장된 클립은 디바이스 픽셀의 GDI 영역입니다. 렌더링되는 비트맵에는 정확하고 다른 어떤 표면에도 무의미하므로, 각 프레임은 영역을 가져온 디바이스 컨텍스트도 기록하고 컨텍스트가 바뀌었으면 복원을 건너뜁니다. 예컨대 transparency group이 자기 레이어 비트맵으로 렌더링되는 동안입니다

텍스트 쪽에서 어드밴스 규칙은 각 글리프를 주어진 너비에서 배치합니다. 단순 폰트가 /Widths 배열을 생략하고 임베드된 폰트 프로그램도 없으면, 어드밴스는 너비 폴백만큼만 좋을 수 있고 §9.4.4는 그 너비가 어디서 오는지 아무 말도 하지 않습니다. 이 영역의 회귀 테스트는 단순 생성 문서에는 거의 없는 파일에서 삽니다. Tf 1과 스케일된 Tm을 가진 파일 하나, 양쪽 정렬 텍스트에 0이 아닌 Tz와 Tw를 가진 파일 하나, 그리고 q ... Q 안의 W n 클립 뒤에 잘린 영역 밖에서 그린 콘텐츠가 오는 파일 하나입니다. 셋째의 올바른 렌더링은 그 밖의 콘텐츠를 온전히 보여줍니다

애플리케이션 코드에서는 이 모든 것이 설정을 필요로 하지 않습니다. 호출은 PDF 페이지를 비트맵으로 렌더링하기에서 보여준 것 하나이고, 두 규칙 모두 그 방식으로 렌더링하는 모든 페이지에 적용됩니다. 지원 Delphi 및 C++Builder 버전과 라이선싱 세부는 HotPDF Delphi PDF Component 페이지에 나열되어 있습니다