v3.539.24 이전 Free Pascal 빌드의 PDF Library for Delphi는 대용량 스트림을 64 KB 청크로 압축하고 청크마다 zlib 헤더와 체크섬을 따로 붙였습니다. 그래서 1 MiB 첨부 파일이 열여섯 개의 zlib 멤버를 끝에서 끝으로 이어 붙인 형태가 됐습니다. ISO 32000-1의 FlateDecode는 정확히 하나의 zlib 스트림을 기대하고, 표준 디코더는 첫 번째 end-of-stream 마커에서 멈추므로 처음 65,536바이트 뒤의 내용은 조용히 사라졌습니다. 수정판에서는 DeflateStream과 InflateStream 양쪽에서 모든 청크에 걸쳐 zlib 상태 하나를 유지합니다
이 버그는 유닛의 FPC 쪽에, 그중에서도 청크 스트리밍 경로에만 존재했고, 그래서 살아남을 수 있었습니다. Delphi 분기는 늘 올바랐고 문자열 기반 헬퍼들도 늘 올바랐으며, 작은 테스트 페이로드는 애초에 청크 코드까지 도달하지 않았습니다. Delphi가 덮어 주던 FPC 이식 버그 다섯 가지와 가까운 사촌격이지만, 이번에는 Delphi 빌드가 뭔가를 덮어 준 게 아닙니다. FPC 분기가 zlib 스트림이 무엇인지에 대한 잘못된 멘탈 모델로 작성되어 있었을 뿐입니다
청크로 나눠 쓴 zlib 스트림을 다른 PDF 뷰어가 잘라 버리는 이유는?
RFC 1950이 정의하는 zlib 스트림은 하나의 컨테이너지지 그 나열이 아니고, 규격을 따르는 inflater는 첫 번째 Adler-32 트레일러를 데이터의 끝으로 취급하기 때문입니다. 형식은 2바이트 헤더, 마지막 블록에 final-block 플래그를 실은 하나의 연속된 RFC 1951 deflate 비트 스트림, 그리고 압축 해제된 전체 바이트에 대한 4바이트 Adler-32 체크섬으로 이루어집니다. ISO 32000-1 §7.4.4는 /FlateDecode를 정확히 이 기준으로 정의합니다. inflate는 트레일러에 도달하면 Z_STREAM_END를 반환하고 남은 입력은 avail_in에 읽지 않은 채로 둡니다. 입장에서 보면 아무 문제가 없으니 오류를 일으키지도 않고, 트레일러 뒤의 바이트는 그냥 무시됩니다. 멤버를 이어 붙이는 것 자체는 gzip에서 정당한 발상입니다(RFC 1952는 한 파일에 여러 멤버를 허용). 직관은 아마 거기서 온 것이겠지만, zlib에는 그런 규칙이 없고 PDF도 요구한 적이 없습니다
예전 FPC DeflateStream은 소스를 64 KB씩 읽어 각 청크를 ZFPCCompress에 넘겼습니다. 이 헬퍼는 자체 deflateInit2를 돌리고 Z_FINISH로 압축한 뒤 deflateEnd를 호출합니다. 그래서 모든 청크가 완전하고 유효하며 스스로 종결된 zlib 스트림으로 나왔고, 함수는 그것들을 하나의 AnsiString으로 이어 붙인 다음 기록했습니다. 결과물은 Flate 데이터처럼 보였고 헤더도 올바랐으며 오류 없이 디코딩됐지만, 디코딩되는 것은 처음 65,536바이트뿐이었습니다. 읽기 쪽 InflateStream에는 거울상 결함이 있었습니다. 압축 입력 64 KB마다 ZFPCInflate를 한 번씩 호출했고, 각 호출은 새 inflateInit2로 시작했습니다. 두 번째 청크는 zlib 헤더 없이 deflate 비트 스트림 한가운데서 시작하므로 새 inflater는 이를 거부하고, 다른 생산자가 만든 완전히 정상인 단일 멤버 스트림도 처음 64 KB의 압축 바이트가 닿는 지점까지만 디코딩됐습니다
// v3.539.24 이전의 FPC DeflateStream (단순화):
// ZFPCCompress는 deflateInit2 / deflate(Z_FINISH) / deflateEnd를 실행하므로
// 64 KB 청크마다 별도의 zlib 멤버가 됩니다
Repeat
ReadCount:= Source.Read(Input[1], ChunkSize);
If (ReadCount> 0) Then
Compressed:= Compressed+ ZFPCCompress(Copy(Input, 1, ReadCount), 6, False);
Until (ReadCount< ChunkSize);
If (Compressed<> '') Then
Target.WriteBuffer(PAnsiChar(Compressed)^, Length(Compressed));
PDF Library for Delphi의 어떤 호출이 청크 경로에 닿았을까?
FPC에서는 1 MiB 이상의 임베디드 파일은 전부 잘못 기록됐고, 스트리밍 API로 꺼내는 Flate 스트림은 압축 크기가 한 청크를 넘는 순간 잘못 읽혔습니다. TPDFStream.ReadFromStream이 들어오는 데이터의 인코딩 방식을 결정합니다. Deflate가 참이고 Stream.Size >= 1048576이면 DeflateStream으로 스트리밍하고, 그 문턱 아래면 소스 전체를 메모리로 읽어 한 번에 처리하는 DeflateStr을 호출합니다. 이 헬퍼는 한 번도 영향받지 않았습니다. ASCII85 더하기 Flate 필터 체인은 크기 검사를 건너뛰고 항상 DeflateStream을 거치므로, 이 경로에서는 64 KB를 넘는 페이로드가 이미 여러 멤버로 쪼개져 있었습니다. 압축을 켠 채 ReadFromStream으로 흘러가는 공개 진입점은 임베디드 파일 작성기들입니다:
TPDFlib.EmbedFile과TPDFlib.AddEmbeddedFile: 디스크의 파일을 읽어/EmbeddedFile스트림에 담습니다TPDFlib.AddAssociatedFileFromStream과TPDFlib.AddAssociatedFileFromFile: 전자송장 XML 같은 원본 데이터를 위한 PDF/A-3 associated-file 작성기입니다- 읽기 쪽에서는
TPDFlib.GetEmbeddedFileContentToStream과GetEmbeddedFileContentToFile:TPDFStream.WriteDecodedToStream을 거쳐InflateStream까지 이어져 디코딩합니다
실패는 모든 계층에서 조용했습니다. 작성기는 /Params /Size와 원본 파일에서 계산한 MD5 /CheckSum을 저장하므로, 첨부 딕셔너리는 전체 크기를 광고하는 동안 스트림에는 열여섯 개의 멤버가 들어 있었습니다. 같은 라이브러리의 Delphi 빌드로 이 파일을 읽으면 첫 번째 Z_STREAM_END에서 깔끔히 멈추고 정확히 65,536바이트를 돌려줍니다. GetEmbeddedFileContentToStream은 1을 반환했습니다. 이 함수가 보고하는 것은 디코딩에서 오류가 났는지 여부지 출력이 /Size와 일치하는지 여부가 아니기 때문입니다. 기가바이트 PDF 병합과 분할로 대용량 문제를 쫓아 본 사람이라면 이 패턴을 압니다. 파일은 열리고 페이지 수는 맞는데, 손상은 누군가 첨부를 열 때만 드러납니다
모든 청크에 걸치는 deflate 상태 하나
PDFlibZLib.pas의 수정된 DeflateStream은 paszlib.TZStream 하나를 초기화하고, 모든 청크를 Z_NO_FLUSH로 deflate에 먹이다가, 맨 마지막에 Z_FINISH로 Z_STREAM_END가 나올 때까지 압축기를 털어냅니다. 그러면 헤더 하나, back-reference가 청크 경계를 넘을 수 있는 deflate 비트 스트림 하나, 전체 입력에 대한 Adler-32 하나가 정확히 나옵니다. 이제 FPC 분기도 Delphi 분기가 늘 가지고 있던 것과 같은 구조를 갖습니다. 압축 결과 전체를 AnsiString에 먼저 이어 붙이는 대신 나오는 대로 출력을 기록하므로, 작성기가 압축 데이터의 사본을 메모리에 통째로 하나 더 만들었다가 대상에 복사하는 일도 사라집니다
// v3.539.24부터의 FPC DeflateStream (오류 경로는 생략)
If (deflateInit2(strm, Level, Z_DEFLATED, 15, 8, Z_DEFAULT_STRATEGY)<> Z_OK) Then
Exit;
Try
Repeat
ReadCount:= Source.Read(Input[0], ChunkSize);
If (ReadCount> 0) Then
Begin
strm.next_in:= Pointer(Input);
strm.avail_in:= ReadCount;
While (strm.avail_in> 0) Do
Begin
strm.next_out:= Pointer(Output);
strm.avail_out:= ChunkSize;
Status:= deflate(strm, Z_NO_FLUSH); // 같은 상태, 멤버 경계 없음
Produced:= ChunkSize- strm.avail_out;
If (Produced> 0) Then
Target.WriteBuffer(Output[0], Produced);
If (Status<> Z_OK) Then
Break;
End;
End;
Until (ReadCount< ChunkSize);
Repeat // 입력 전체에 트레일러 하나
strm.next_out:= Pointer(Output);
strm.avail_out:= ChunkSize;
Status:= deflate(strm, Z_FINISH);
Produced:= ChunkSize- strm.avail_out;
If (Produced> 0) Then
Target.WriteBuffer(Output[0], Produced);
Until (Status= Z_STREAM_END);
Finally
deflateEnd(strm);
End;
InflateStream은 대칭으로 다시 쓰였습니다. inflateInit2 하나, 현재 청크가 소진되고 출력 버퍼가 더는 가득 차지 않을 때까지 inflate를 계속 호출하는 내부 루프, 그리고 Z_STREAM_END에서의 정지입니다. 알아 둘 부작용이 하나 있습니다. 예전 청크 작성기는 레벨 6을 하드코딩했지만 새 구현은 PLDeflateLevel을 따르므로, TPDFlib.SetCompressionLevel(1..9)로 지정한 레벨이 이제 FPC의 대용량 임베디드 파일에도 적용됩니다. Delphi에서 PDF 파일 크기 줄이기에서처럼 아카이브 출력용으로 압축을 이미 튜닝하고 있다면 의미 있는 변화입니다
Flate 스트림이 단일 zlib 멤버인지 검증하는 방법은?
평범한 zlib 디코더로 압축을 풀고 Z_STREAM_END가 반환됐을 때 두 가지를 확인합니다. 디코딩된 길이가 소스 길이와 같은지, 그리고 avail_in이 0인지입니다. end 마커 뒤에 입력이 남아 있으면 이어 붙인 스트림의 특징입니다. 수정은 이렇게 검증했습니다. 64 KB 청크 네 개에 걸치는 200 KB 테스트 데이터를 새 DeflateStream에 통과시키니 534바이트짜리 단일 zlib 스트림이 나왔고, 기본 zlib 디코더가 남는 입력 없이 200,000바이트 전체를 복원했으며, 같은 검사가 i386 크로스 컴파일 FPC 타깃에서도 통과했습니다. 아래 루틴은 그 검사의 FPC 버전으로, 검사 대상 코드를 신뢰하지 않도록 paszlib을 직접 써서 만들었습니다
uses Classes, SysUtils, paszlib, PDFlibZLib;
function IsSingleZlibMember(Packed: TMemoryStream; out Decoded: Int64): Boolean;
var
strm: TZStream;
Buf: array[0..65535] of Byte;
Status: Integer;
begin
Result:= False;
Decoded:= 0;
FillChar(strm, SizeOf(strm), 0);
if inflateInit2(strm, 15) <> Z_OK then
Exit;
try
strm.next_in:= Packed.Memory;
strm.avail_in:= Cardinal(Packed.Size);
repeat
strm.next_out:= @Buf;
strm.avail_out:= SizeOf(Buf);
Status:= inflate(strm, Z_NO_FLUSH);
until Status <> Z_OK;
Decoded:= strm.total_out;
// 멤버 하나가 마지막 입력 바이트에서 정확히 끝납니다
Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
finally
inflateEnd(strm);
end;
end;
// 기본 64 KB 청크로 200,000바이트를 DeflateStream에 밀어 넣고
// 전체 길이로 디코딩되는 멤버 하나를 요구합니다
Source.Position:= 0;
DeflateStream(Source, Packed);
if not (IsSingleZlibMember(Packed, Decoded) and (Decoded = Source.Size)) then
raise Exception.Create('DeflateStream produced more than one zlib member');
애플리케이션 수준에서 유용한 단언은 라이브러리가 대신 해 주지 않는 것입니다. 첨부에서 나온 결과를 넣을 때 기록된 /Params /Size와 비교하세요. tag 5로 호출하는 GetEmbeddedFileIntProperty가 그 기록된 크기를 돌려주고, 임베디드 파일 인덱스는 1 기반이며, 스트리밍 경로를 발동하려면 페이로드가 최소 1 MiB는 되어야 합니다. 같은 테스트를 출하하는 모든 컴파일러에서 돌리세요. 원래 결함은 Delphi에서는 통과하고 FPC에서만 실패했습니다
procedure CheckLargeAttachmentRoundTrip(const PayloadFile, OutFile: string);
var
PDF: TPDFlib;
Extracted: TMemoryStream;
I, Declared: Integer;
begin
PDF:= TPDFlib.Create;
try
PDF.NewDocument;
PDF.AddStandardFont(4);
PDF.DrawText(80, 100, 'Large attachment round trip');
// 1 MiB 이상은 ReadFromStream의 청크 DeflateStream 경로로 들어갑니다
if PDF.EmbedFile('Payload', PayloadFile, 'application/octet-stream') <> 1 then
raise Exception.Create('EmbedFile failed');
if PDF.SaveToFile(OutFile) <> 1 then
raise Exception.Create('SaveToFile failed');
finally
PDF.Free;
end;
PDF:= TPDFlib.Create;
Extracted:= TMemoryStream.Create;
try
if PDF.LoadFromFile(OutFile, '') = 0 then
raise Exception.Create('LoadFromFile failed');
for I:= 1 to PDF.EmbeddedFileCount do
begin
Extracted.Clear;
if PDF.GetEmbeddedFileContentToStream(I, Extracted) <> 1 then
raise Exception.CreateFmt('Attachment %d could not be decoded', [I]);
Declared:= PDF.GetEmbeddedFileIntProperty(I, 5); // /Params /Size
if Extracted.Size <> Declared then
raise Exception.CreateFmt('Attachment %d truncated: %d of %d bytes',
[I, Extracted.Size, Declared]);
end;
finally
Extracted.Free;
PDF.Free;
end;
end;
이 수정이 바꾸지 않는 것은?
FPC 분기는 관대한 디코딩 규칙을 그대로 유지하고, 이전 FPC 빌드가 이미 기록한 파일을 복구하지도 않습니다. MaxOutput에 도달하면 FPC InflateStream은 한계에서 잘라 내고 반환하는 반면 Delphi 분기는 ERangeError를 일으키며, inflate가 데이터 오류를 보고할 때 FPC는 여전히 부분적으로 디코딩된 출력을 받아들입니다. 잘리거나 체크섬이 깨진 스트림을 내놓는 PDF 생산자가 있기 때문입니다. v3.539.24 이전 FPC 빌드가 기록한 PDF에는 여전히 이어 붙인 멤버가 들어 있고, 수정된 리더도 다른 모든 리더처럼 첫 번째 Z_STREAM_END에서 멈춥니다. 그런 파일을 라이브러리 안에서 디코딩하고 다시 인코딩해서 고치려 하지 마세요. 64 KB 잘림이 영구히 굳어질 뿐입니다. 첨부는 원본 소스에서 다시 넣어 주는 편이 맞습니다. FPC 루프는 또한 청크보다 적게 반환하는 첫 읽기에서 여전히 끝나는데, 이것이 데이터 끝 신호가 되는 것은 TFileStream이나 TMemoryStream 같은 스트림에서뿐이므로, AddAssociatedFileFromStream에 넘기는 커스텀 TStream은 TMemoryStream으로 복사해 넣는 것이 가장 안전합니다. Delphi 분기, DeflateStr과 InflateStr 헬퍼, 그리고 일반 Flate 경로의 1 MiB 미만 스트림은 예전과 정확히 같게 동작합니다
수정된 FPC DeflateStream과 InflateStream은 PDF Library for Delphi v3.539.24에 들어갑니다. 이 라이브러리는 하나의 소스 트리에서 Delphi, C++Builder, Free Pascal을 대상으로 하며, 이제 대용량 첨부 파일도 FPC 빌드에서 예전 Delphi와 똑같이 바이트 단위로 그대로 돌아와야 합니다