PDF Library for Delphi는 세이브 시점 피홀 콘텐츠 스트림 최적화에서 identity 텍스트 행렬 연산자 1 0 0 1 0 0 Tm을, 텍스트 행렬과 텍스트 라인 행렬이 이미 identity일 때만 제거합니다. BT 바로 뒤, 또는 앞선 identity Tm 바로 뒤 말이죠. identity cm은 여전히 항상 버려집니다. cm은 CTM을 곱하는 반면 Tm은 두 텍스트 행렬을 통째로 교체하기 때문입니다. v3.539.28부터 그 외의 identity Tm은 모두 스트림에 남습니다
이번 수정이 잡는 버그는 조용한 종류입니다. 리포트 제너레이터가 BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET를 내보내면서 identity Tm이 자기 위치 로직을 적용하기 전에 둘째 문자열을 텍스트 공간 원점으로 돌려 보내 주길 기대하는 경우를 상상해 보세요. 예전 옵티마이저는 identity 행렬을 맞추는 여섯 숫자를 보고 이 연산자는 뭘 바꿀 수 없다고 판단해 삭제했습니다. 아무것도 실패하지 않고, 아무것도 경고를 기록하지 않았고, 저장된 페이지는 같은 베이스라인 위에서 "Invoice" 바로 뒤에 "Total"을 그렸습니다. 고객이 PDF를 인쇄하기 전까지는 아무도 눈치채지 못하는 부류의 결함입니다
1 0 0 1 0 0 Tm이 항상 no-op이 아닌 이유는?
identity Tm은 자신이 교체할 두 행렬이 이미 identity를 담고 있을 때에만 no-op이고, 그것은 자기 피연산자의 속성이 아니라 앞선 연산자들의 속성입니다. ISO 32000-1 §9.4.1은 BT가 텍스트 행렬(Tm)과 텍스트 라인 행렬(Tlm)을 모두 identity로 초기화한다고 말하고, §9.4.2는 Tm을 그것들을 주어진 값으로 세팅하는 것으로 정의하지, 이어 붙이는 것으로 정의하지 않습니다. cm(§8.4.4)과 비교해 보세요. 이것은 현재 변환 행렬을 오른쪽 곱합니다. identity를 곱하면 어떤 CTM도 그대로이므로 1 0 0 1 0 0 cm은 어디서든 지워도 안전합니다. 텍스트 오브젝트 안에서는 그림이 다릅니다. Td, TD, T*와 non-identity Tm은 모두 Tlm을 옮기고, 모든 텍스트 표시 연산자(Tj, TJ, ', ")는 그린 글리프 너비만큼 Tm을 전진시킵니다. 그중 하나라도 지난 뒤의 identity Tm은 원점으로의 진짜 리셋입니다. 콘텐츠 스트림 CTM과 텍스트 행렬 상태 트래커로 텍스트 위치를 손으로 추적해 본 적이 있다면, 이것은 상태를 이어 붙이는 것과 교체하는 것의 같은 구분입니다
역방향 스캔이 어떤 identity Tm을 버릴지 판단하는 방법
TPDFContentPeepholeOptimizer.RemoveIdentityMatrices는 이제 각 identity Tm에서 뒤로 걸어, 스캔이 먼저 BT나 다른 identity Tm에 닿을 때만 그것을 버립니다. 앞선 identity Tm은 유지되든 삭제 대상으로 지정된 직후든 어느 쪽이든 BT가 하듯 두 행렬을 identity에 남겨 두었으므로 개수에 포함됩니다. 이 규칙은 만날 수 있는 모든 연산자를 두 그룹 중 하나로 분류합니다:
- 멈추고 Tm을 유지:
Td,TD,T*, non-identityTm,Tj,TJ,',",ET, 파서가 인식하지 못하는 임의의 연산자, 또는 스트림의 시작 - 건너뛰고 계속 스캔:
Tf,Tc, 색 세터,gs, marked-content 연산자,cm처럼 Tm이나 Tlm을 건드리지 않는 연산자들
보수적인 케이스들은 의도된 것입니다. 모르는 연산자는 무엇이든 될 수 있으므로 스캔은 그 너머를 추론하지 않습니다. ET는 텍스트 오브젝트를 닫으므로, 그 뒤의 Tm은 행렬 값을 보증해 주는 BT가 없습니다. 스캔은 한 번에 하나의 콘텐츠 스트림에서 동작하는데, /Contents가 배열인 페이지에서 중요해집니다. 텍스트 오브젝트 한가운데서 시작해 자기 BT가 없는 레이어는, 앞 레이어가 그것을 잉여로 만들었을 상황에서도 identity Tm을 유지합니다. 별난 파일에서 몇 바이트를 희생하는 대신 글리프는 결코 움직이지 않습니다. 문자-콘텐츠 바이트 매핑 워크스루처럼 인스트럭션 수준에서 페이지 텍스트를 편집한다면, 옵티마이저가 다시 쓰는 것은 같은 파싱된 TPDFContentProgram 모델입니다
uses
PDFlibContentModel, PDFlibContentOptimize;
function OptimizeSnippet(const Source: AnsiString): AnsiString;
var
Prog: TPDFContentProgram;
Optimizer: TPDFContentPeepholeOptimizer;
begin
Result := Source;
Prog := TPDFContentProgram.Create;
try
if not Prog.Parse(Source) then
Exit; // 손상된 스트림: 바이트는 그대로 둠
Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
try
Optimizer.Run; // 제거된 명령 수를 반환
finally
Optimizer.Free;
end;
Result := Prog.Emit; // 한 줄에 명령 하나
finally
Prog.Free;
end;
end;
// 제거됨: BT 바로 뒤의 Tm, 연속한 두 identity Tm 중 둘째
// OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// 유지됨: Td 뒤, Tj 뒤, non-identity Tm 뒤, 또는 BT 밖의 Tm
// OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')
도입부의 인보이스 스트림에 헬퍼를 돌리면 identity Tm은 살아남습니다. 역방향 스캔이 BT에 닿기 전에 Tj를 만나기 때문입니다. BT와 identity Tm 사이에 /F1 12 Tf, 2 Tc, 0 g을 놓아도 여전히 제거됩니다. 그중 하나도 텍스트 행렬을 건드리지 않으니까요. BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm 같은 시퀀스는 정확히 연산자 하나를 잃습니다. 첫 identity Tm이 Td가 옮긴 행렬을 리셋하고, 잉여인 것은 둘째뿐입니다
피홀 옵티마이저는 실제로 언제 도는가?
옵티마이저는 압축 패스 동안, 그러니까 TPDFPageTree.Compress 안에서만, 그리고 Flate로 이미 압축되지 않은 콘텐츠 스트림에만 돕니다. TPDFlib.SetOptimizeContentStreams(1)이 디폴트이고, 같은 스위치가 TPDFlibSaveOptions의 OptimizeContentStreams 필드로 노출됩니다. CompressContent와 CompressPage 둘 다 이를 존중합니다. /Filter가 이미 /FlateDecode인 스트림은 아예 건너뛰어지므로, 기존 압축 PDF를 불러들여 다시 저장해도 연산자는 다시 쓰이지 않습니다. 스트림 파싱에 실패하면 원래 디코딩된 바이트가 그대로 압축됩니다. TPDFlib.NormalizeContentStreams는 콘텐츠를 정규 간격과 숫자로 파싱해 재출력하지만 옵티마이저를 부르지 않으므로, 피홀 규칙이 크기에 얼마나 기여하는지 보고 싶을 때 유용한 베이스라인이 됩니다. 폰트 서브셋팅으로 PDF 파일 크기 최적화에서 다루는 더 큰 절감과 함께요
var
Lib: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') <> 1 then
Exit;
// 압축 안 된 스트림은 피홀 규칙을 거친 뒤 Flate로
Lib.SetOptimizeContentStreams(1);
Lib.CompressContent;
Lib.SaveToFile('report-optimized.pdf');
// 번들 세이브 옵션으로 같은 선택, False는 옵트아웃
Options.CompressContent := True;
Options.CompressFonts := True;
Options.CompressImages := True;
Options.Linearize := False;
Options.KeepModDate := False;
Options.OptimizeContentStreams := False;
Options.GarbageCollect := False;
Options.PackObjectStreams := True;
Lib.SaveToFileOptions('report-plain.pdf', Options);
finally
Lib.Free;
end;
end;
옛 회귀 테스트가 실제로 보장한 것은?
옛 회귀 테스트가 보장한 것은 한 모양뿐이었습니다. BT 바로 뒤의 identity Tm은 제거됩니다. Peephole_RemovesIdentityTextMatrix는 BT 1 0 0 1 0 0 Tm (hello) Tj ET를 옵티마이저에 먹이고 Tm이 남지 않았음을 어설트합니다. 이전 릴리스에서 Tlm이 identity가 아닐 때 identity Tm을 버리는 것은 안전하지 않다고 이미 적어 두었으면서도, 테스트가 그 동작을 "잠가" 놓았다는 이유로 그대로 유지했습니다. 다시 정독해 보면 그 테스트는 Td 뒤나 표시된 텍스트 뒤의 identity Tm에 관해 아무것도 말하지 않습니다. 표본 하나의 커버리지를 규칙 전체의 계약처럼 다룬 것이 실제 실수였죠. 수정은 원래 케이스가 계속 통과하게 두면서, 제거 가능한 모양과 유지되는 모양을 모두 못 박는 여섯 케이스를 추가합니다. 임의의 텍스트 오브젝트 밖의 Tm 하나와 ET 뒤를 따르는 것도 포함입니다
트레이드오프는 적어 두고 보면 받아들이기 쉽습니다. 모든 텍스트 오브젝트를 BT 1 0 0 1 0 0 Tm ...으로 감싸는 제너레이터는 여전히 그 잉여 연산자의 제거를 받습니다. 절감의 거의 전부가 그곳에서 나왔으니까요. 옵티마이저가 포기하는 것은 텍스트 오브젝트 한가운데의 이따금 있는 identity Tm, Flate가 보기도 전에 페이지당 한 줌의 바이트입니다. 그 대가로 모듈 헤더가 분명히 밝히는 보장을 얻습니다. 모든 변환은 출력 등가이며 보이는 페이지를 절대 바꾸지 않는다는 것이죠. 텍스트를 움직이는 크기 옵티마이저는 옵티마이저가 아니라 압축률 좋은 렌더링 버그입니다
여기서 설명한 콘텐츠 스트림 파서, 피홀 옵티마이저, 세이브 시점 압축 옵션은 모두 PDF Library for Delphi and C++Builder에 실려 나갑니다. NormalizeContentStreams, CompressContent, TPDFlibSaveOptions도 노출되므로 각 문서를 어떻게 쓸지 조정할 수 있습니다