HotPDF Delphi Component의 페이지 렌더러는 이제 텍스트 공간에서 글리프 변위를 계산해 텍스트를 전진시킵니다. ISO 32000-1 §9.4.4가 정의하는 대로 tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th를 계산한 다음, HPDFTranslateTextMatrix로 텍스트 행렬의 선형 부분을 통해 텍스트 행렬을 움직입니다. 클리핑은 q 프레임마다 저장되고 Q에서 복원되지만, GDI 영역은 그 프레임이 실제로 클립을 바꿀 때만 캡처됩니다. 두 수정 모두 HotPDF 2.754.0에 들어갔고, 둘 다 단어가 뭉개져 렌더링되거나 클립 영역이 자기 Q를 넘어 새는 실제 페이지에서 나왔습니다. 첫 버그는 생산자가 폰트 크기를 행렬에 넣기 전까지는 올바르게 보이는 산술입니다. 둘째는 병렬 렌더 가속을 거의 잃을 뻔한 정확성 수정이고, 속도를 되찾은 방법은 GDI 기반 PDF 디바이스를 작성하는 사람이라면 알아 둘 가치가 있습니다
PDF가 Tf 1을 쓰면 텍스트가 뭉치는 이유는?
옛 어드밴스 코드가 텍스트 공간 거리를 Tm의 이동 성분에 곧장 더했기 때문입니다. 텍스트 공간과 유저 공간이 항상 같은 스케일인 것처럼요. 실제 세상 생산자 중 상당수는 Tf로 폰트 크기를 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 문자만큼 착지하므로, 본문 한 줄은 왼쪽 여백의 검은 얼룩으로 렌더링되는 반면 같은 파일은 다른 모든 뷰어에서 완벽해 보였습니다
// 크기를 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 아래에서 양쪽 정렬된 줄은 워드 사이 간격을 거의 다 잃었습니다. 수평 스케일링 Th는 글리프 너비에는 적용되지만 Tc나 Tw에는 적용되지 않았고, TJ 커닝 조정은 그것을 완전히 건너뛰었습니다. 렌더 모드 3 보이지 않는 텍스트, 그러니까 OCR 텍스트 레이어가 쓰는 종류, 그리고 숨겨진 optional content 안의 텍스트를 전진시키는 비페인팅 경로는 같은 산술의 private 사본을 갖고 있었으므로, 보이지 않는 런 뒤에 그려지는 것은 모두 잘못된 위치에서 시작했습니다. 렌더러의 텍스트 상태 버그는 요란하게 실패하는 일이 드뭅니다. 한때 오류 한 번 없이 Tc, Tw, Tz를 0으로 만들었던 operand 인덱스와 리소스 이름 버그처럼, 이것들은 라이브러리 자체 출력에서는 그럴듯한 페이지를 만들고 다른 생산자의 파일에서만 깨졌습니다
ISO 32000-1 §9.4.4는 글리프 어드밴스를 어떻게 정의할까?
ISO 32000-1 §9.4.4는 어드밴스를 전적으로 텍스트 공간에서 정의하고 그것을 이동 행렬로서 텍스트 행렬에 적용합니다. 그러니 답은 tx를 먼저 계산하고 스케일링, 회전, 스큐는 Tm에 맡기는 것입니다. 수평 쓰기에서 tx는 ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th이며, w0는 em의 천분율 글리프 너비, Tj는 TJ 조정, Th는 100으로 나눈 Tz입니다. 새 Tm은 [1 0 0 1 tx 0] × Tm이고, HotPDF에서는 헬퍼 HPDFTranslateTextMatrix입니다. e와 f에 직접 쓰는 대신 행렬 계수 a, b, c, d를 통해 X와 Y를 더합니다. §9.3.3에 따라 Tw는 단일 바이트 문자 코드 32에만 적용되므로, 다중 바이트 CID 코드는 수평 경로에서 워드 스페이싱을 받는 일이 없습니다. 같은 헬퍼가 이제 Td, TD, T*, '와 " 연산자, TJ 조정, 숨겨진 텍스트 경로를 구동하므로, 규칙을 소유하는 함수는 하나입니다
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;
// 수평 글리프 어드밴스, ISO 32000-1 9.4.4
W := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
Adv := Adv + State.Text.WordSpace; // 텍스트 공간의 Tw, 스케일 없음
Adv := Adv * State.Text.HorizScale / 100; // Th는 합 전체에 적용
HPDFTranslateTextMatrix(Tm, Adv, 0);
// TJ 숫자 요소: 같은 공간, 같은 Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);
글리프 배치도 같은 논리를 따라야 했습니다. 임베드된 아웃라인이 없어 렌더러가 GDI TextOutW로 폴백할 때는 이제 Th를 포함해 CTM × Tm × rise × em 스케일로 전체 글리프 행렬을 만들고, SaveDC / RestoreDC 쌍 안에서 GM_ADVANCED 모드의 SetWorldTransform으로 설치합니다. GDI 폰트는 고정 1000유닛 높이로 만들고 크기 잡기는 변환이 맡으므로, 회전하고 스큐된 텍스트는 변환된 원점에서 똑바로 그려지는 대신 자기 방향을 유지합니다. 수직 쓰기 모드만이 의도된 비대칭입니다. WMode 1 폰트는 수직 메트릭만큼 y 축을 따라 내려가며, 수평 스케일링은 그 축에 적용되지 않습니다
q/Q는 PDF 그래픽 상태에서 실제로 무엇을 저장할까?
ISO 32000-1 §8.4.2는 현재 클리핑 경로를 그래픽 상태의 일부로 나열하므로, Q는 수치 파라미터만이 아니라 클립을 짝이 되는 q 시점 그대로 복원해야 합니다. HotPDF는 이미 CTM과 색, 라인 파라미터, 텍스트 상태를 담은 그래픽 상태 스택을 유지하고 있었지만, GDI는 클립을 그 스택 밖의 디바이스 컨텍스트에 둡니다. 그래서 수치 상태의 사본은 클립을 제외한 모든 것을 복원했고, q ... Q 블록 안에서 W n으로 설치된 클립은 페이지의 이후 모든 연산을 계속 잘라 냈습니다. Form XObject가 같은 실패로 가는 두 번째 경로를 추가했습니다. §8.10은 폼에 자기 콘텐츠를 둘러싼 암묵적 save와 restore를 주는데, 실제 폼 콘텐츠는 스펙이 짝을 요구함에도 자기 q 연산자를 불균형으로 남겨 두기도 합니다. 렌더러는 이제 폼을 돌리기 전에 CaptureClipBeforeChange와 SaveDC를 호출하고, 폼이 끝난 뒤 진입 깊이보다 깊게 저장된 영역을 버리고 RestoreDC를 호출합니다. 그래서 저장된 각 HRGN은 정확히 하나의 해제 경로를 가집니다
THPDFSavedClipState로 지연 클립 캡처
출하된 수정은 q마다 THPDFSavedClipState 레코드 하나를 저장하되, 비싼 부분은 프레임이 클립을 처음 바꿀 때까지 미룹니다. 레코드는 영역 핸들, 자기가 속한 스택 깊이, 취해진 디바이스 컨텍스트, Captured 플래그를 담습니다. DevPushState는 깊이와 DC만 채우고 프레임 배열을 16부터 두 배씩 늘리므로, q 1 0 0 1 x y cm ... Q로 가득한 콘텐츠 스트림은 GDI 오브젝트를 전혀 할당하지 않습니다. 클리핑을 바꾸려 하는 연산자들, 그러니까 pending인 W나 W*가 붙은 경로 페인팅, n 연산자, 패턴 채움, 폼 진입이 먼저 CaptureClipBeforeChange를 호출합니다
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
Index, ClipResult: Integer;
Region: HRGN;
begin
ClearSavedClipRegions(FGSStack.Count);
Index := FSavedClipCount - 1;
if (Index < 0) or FSavedClips[Index].Captured or
(FSavedClips[Index].StackDepth <> FGSStack.Count) or
(FSavedClips[Index].DC <> FDC) then Exit; // 이미 저장됨, 또는 우리 것이 아님
Region := CreateRectRgn(0, 0, 0, 0);
if Region = 0 then RaiseLastOSError;
ClipResult := GetClipRgn(FDC, Region); // 0은 클립이 아예 없음을 뜻함
if ClipResult <= 0 then
begin
DeleteObject(Region);
Region := 0;
if ClipResult < 0 then RaiseLastOSError;
end;
FSavedClips[Index].Region := Region;
FSavedClips[Index].Captured := True;
end;
procedure THPDFPageRenderer.DevPopState;
var
Index: Integer;
begin
ClearSavedClipRegions(FGSStack.Count);
Index := FSavedClipCount - 1;
if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
begin
if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
SelectClipRgn(FDC, FSavedClips[Index].Region); // Region 0은 클립을 제거
if FSavedClips[Index].Region <> 0 then
DeleteObject(FSavedClips[Index].Region);
Dec(FSavedClipCount);
end;
FGSStack.Pop;
end;
열성 버전의 측정된 비용이 이 설계가 존재하는 이유입니다. 첫 올바른 구현은 q마다 GDI 영역을 만들고 읽었고, 수치 변환으로 주로 이루어진 페이지에서 렌더러 스레드는 래스터라이징 대신 GDI 영역 오브젝트를 두고 경쟁하며 시간을 보냈습니다. 병렬 렌더 파이프라인은 기대 이득에서 단일 스레드 처리량의 대략 1.13배에서 1.20배로 떨어져 벤치마크 스위트의 1.5배 가속 게이트를 실패했습니다. 지연 캡처와 재사용된 프레임 용량으로 같은 벤치마크는 원래의 1.5배 게이트를 다시 통과합니다. 작은 TrueType 글리프 안티앨리어싱이 같은 릴리스에 들어왔고 뻔한 용의자였지만, 회귀는 영역 할당으로 거슬러 올라갔습니다. 가장 최신 기능을 탓하기 전에 측정하라는 교훈으로 남을 만한 사례입니다
이 접근의 한계는 어디에 있을까?
저장된 클립은 디바이스 픽셀의 GDI 영역이므로, 렌더링되는 비트맵에는 정확하고 다른 대상에는 무의미합니다. 그래서 각 프레임은 자기 디바이스 컨텍스트를 기록하고, DC가 바뀌었으면 DevPopState는 복원을 건너뜁니다. 예컨대 transparency group이 자기 레이어 비트맵으로 렌더링되는 동안입니다. GetClipRgn이 0을 반환하는 것은 클립이 없다는 정당한 결과이고, SelectClipRgn(FDC, 0)으로 복원하는 것이 짝이 되는 q에 존재하지 않았던 클립을 올바르게 제거하는 방법입니다. 텍스트 쪽에서 이 수정은 각 글리프가 가는 곳을 고치지만 너비를 지어내지는 않습니다. 폰트가 /Widths 배열을 생략하고 임베드된 프로그램을 쓸 수 없으면, 어드밴스는 여전히 너비 폴백만큼만 좋습니다. 이 영역을 회귀 테스트할 때는 Tf 1과 스케일된 Tm을 가진 픽스처 하나, 0이 아닌 Tz와 Tw를 가진 픽스처 하나, 그리고 q ... Q 안의 클립 뒤에 그 밖의 콘텐츠가 오는 픽스처 하나를 최소한 유지하세요. 라이브러리 자체가 생성한 문서에는 그중 어느 것도 나타나지 않으니까요
애플리케이션 코드에서 렌더러를 구동한다면 PDF 페이지를 비트맵으로 렌더링하기에서 설명한 호출 패턴은 아무것도 바뀌지 않고, 이전에 얼룩진 줄이나 잘린 콘텐츠를 보여 주던 페이지는 2.754.0 이상에서 그냥 올바르게 렌더링되어야 합니다. 컴포넌트 상세와 지원 Delphi 및 C++Builder 버전, 라이선싱은 HotPDF Delphi PDF Component 제품 페이지에 있습니다