Free Pascal에서 청크 FlateDecode 작성기는 모든 청크에 걸쳐 zlib 상태 하나를 유지해야 합니다. 유혹적인 지름길, 즉 64 KB 청크마다 따로 압축하는 방식은 청크마다 자체 zlib 헤더와 체크섬을 만들어 내므로 1 MiB 첨부 파일이 열여섯 개의 zlib 멤버를 끝에서 끝으로 이어 붙인 형태가 됩니다. ISO 32000-1의 FlateDecode는 정확히 하나의 zlib 스트림을 기대하고, 표준 디코더는 첫 번째 end-of-stream 마커에서 멈추므로 처음 65,536바이트 뒤의 내용은 조용히 사라집니다. PDF Library for Delphi는 DeflateStream과 InflateStream 양쪽에서 모든 청크에 걸쳐 zlib 상태 하나를 유지합니다
이 함정은 크로스 컴파일러 유닛의 FPC 쪽에, 그중에서도 청크 스트리밍 경로에만 존재합니다. 그래서 놓치기 쉽습니다. Delphi 분기와 문자열 기반 헬퍼는 각자 단일 zlib 스트림을 만들어 내고, 작은 테스트 페이로드는 애초에 청크 코드까지 도달하지 않습니다. Delphi가 감춰 주는 FPC 이식 함정 다섯 가지와 가까운 사촌격이지만, 이번에는 Delphi가 뭔가를 덮어 주고 있는 게 아닙니다. 지름길은 그저 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바이트뿐입니다. 읽기 쪽에는 거울상의 함정이 있습니다. 압축 입력 64 KB마다 ZFPCInflate를 한 번씩 호출하면 매번 새 inflateInit2가 시작됩니다. 두 번째 청크는 zlib 헤더 없이 deflate 비트 스트림 한가운데서 시작하므로 새 inflater는 이를 거부하고, 다른 생산자가 만든 완전히 정상적인 단일 멤버 스트림도 처음 64 KB의 압축 바이트가 닿는 지점까지만 디코딩됩니다
// 순진한 청크 방식 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에 먼저 이어 붙이는 대신 나오는 대로 출력을 기록하므로, 작성기가 압축 데이터 사본을 메모리에 통째로 하나 더 만들었다가 대상에 복사하는 일은 없습니다
// zlib 상태 하나를 쓰는 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에서의 정지입니다. 알아 두면 좋은 부작용이 하나 있습니다. 작성기가 고정 레벨 헬퍼를 거치지 않고 deflateInit2를 직접 호출하므로 PLDeflateLevel이 존중되고, TPDFlib.SetCompressionLevel(1..9)로 설정한 레벨이 FPC의 대용량 임베디드 파일에도 적용됩니다. Delphi에서 PDF 파일 크기 줄이기에서 다루듯 아카이브 출력용으로 압축 레벨을 이미 조정하고 있다면 이 이야기가 해당됩니다
Flate 스트림이 단일 zlib 멤버인지 검증하는 방법은?
평범한 zlib 디코더로 인플레이트하고, Z_STREAM_END가 반환될 때 두 가지를 확인하세요. 디코딩된 길이가 소스 길이와 같은지, avail_in이 0인지. 끝 마커 뒤에 입력이 남아 있으면 그것이 이어 붙인 스트림의 징후입니다. 아래 루틴은 그 검사의 FPC 버전으로, 테스트 대상 코드를 신뢰하지 않도록 paszlib 위에 직접 만들었으며, 200,000바이트, 즉 64 KB 청크 네 개를 DeflateStream에 밀어 넣는 탐지 코드가 뒤따릅니다
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 생산자가 있기 때문입니다. 무엇이 썼든 이미 이어 붙인 멤버를 담고 있는 PDF는 모든 리더에서 첫 번째 Z_STREAM_END에서 멈춥니다. 그런 파일을 라이브러리 안에서 스트림을 디코딩하고 다시 인코딩해서 고치려 하지 마세요. 64 KB 잘림이 영구히 굳어질 뿐입니다. 첨부는 원본 소스에서 다시 넣어 주는 편이 맞습니다. FPC 루프는 청크 분량보다 적게 읽히면 데이터의 끝으로 취급하는데, 이것은 TFileStream이나 TMemoryStream 같은 스트림에서 성립하므로, AddAssociatedFileFromStream에 넘기는 커스텀 TStream은 TMemoryStream으로 복사해 넣는 것이 가장 안전합니다
단일 상태의 FPC DeflateStream과 InflateStream은 PDF Library for Delphi에 들어 있습니다. 이 라이브러리는 하나의 소스 트리에서 Delphi, C++Builder, Free Pascal을 대상으로 하므로, 대용량 첨부 파일은 FPC 빌드에서도 Delphi와 똑같이 바이트 단위로 그대로 돌아옵니다