PDF Library for Delphi found five decoder defects while bringing its CCITT, TIFF, PNG, Flate, and stream-buffer code up on Free Pascal, and every one of them had passed the full Delphi test suite for years. None was a compiler bug. Each was Pascal that Delphi happened to execute correctly because of an implementation detail: a hidden result parameter that aliased the caller's array, an out-of-range branch nobody ever read past, a zero-length buffer whose only guard was a range-check switch, a 1-based offset that only one code path ever passed as 1, and a TStream.Read contract that in-memory streams never exercise. Change the compiler, or feed the same code a malformed file, and the accident stops holding
What follows is the specific shape of each one, the fix, and the discipline that came out of it: the same source now has to produce the same document semantics on both compilers, and a test include checks that it does. The sibling article on hardening a Pascal PDF parser against malicious files covered integer width, recursion depth, and uninitialized buffers. This one is about a different failure class: code that was wrong all along and had a compiler quietly covering for it
Why does a function returning a dynamic array work without SetLength on Delphi?
Because Delphi passes the caller's own variable in as the hidden result parameter, so a function that never allocates its result can still write into an array the caller allocated. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray is the reference-line lookup at the heart of two-dimensional Group 3 and Group 4 decoding: given the current position a0 and the color of the current run, it searches the previous scanline's changing elements, the b1 and b2 of the ITU-T T.4 and T.6 two-dimensional coding scheme, and returns them as a two-slot array. The original function wrote Result[0] and Result[1] and never called SetLength on Result at all
That should fault on the first write, and on Free Pascal it does. On Delphi it never did, because both call sites in the decoder look like this: declare b: TCCITTIntegerArray, run SetLength(b, 2) once before the scanline loop, then inside the loop assign b := GetNextChangingElement(a0, IsWhite) and read b[0] and b[1]. The Delphi language guide states that a function whose result is a long string, dynamic array, or other managed type receives that result as an additional var parameter, and in practice the compiler passes the address of the assignment target. So Result inside the function is b itself, already two elements long, and every write lands in memory the caller owns. Free Pascal hands the function a fresh nil array and assigns it to b afterward, which is the reading of the contract the code should have been written against in the first place
The aliasing also carried a semantic the decoder depends on. Result[0] is only assigned when the scan finds an element greater than a0, and Result[1] only when there is an element after it, so on a miss the slots keep whatever the previous iteration left in b. The obvious fix, allocate two slots and zero them on every call, would have destroyed that carry-over and changed decoded output on Delphi. The fix that shipped is a guard instead of a reset: on Delphi it is dead code and the decode path stays byte for byte what it was, and on Free Pascal it turns a fault into the intended behavior. That asymmetry is the whole point, since the fix had to be a no-op on the compiler where the code was already producing verified output
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi arrives here with the caller's two-element array aliased
// as Result, so this is a no-op there. FPC arrives with nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] are still written only on a hit, so a miss
// keeps the previous iteration's values exactly as before
End;
A count that outlived its data: the TIFF directory entry
When you invalidate an array, you have to invalidate its count in the same statement, or the count will be believed by code that never sees the array. A TIFF image file directory entry (TIFF 6.0 §2, the 12-byte layout of tag, type, count, and value-or-offset) carries a 32-bit count straight out of the file, and PDF Library for Delphi reads each one through PopDE: TTIFFEntry, a record with Tag, TagType, Length, Offset, and the decoded IntegerValues and DoubleValues arrays. The original code checked whether Offset + TypeSize * Length ran past the end of the file, and if it did, it set both arrays to zero length. It left Result.Length at the value from the file
Two things went wrong from there. The function ends with a fallback that reads "if Length is zero, give the entry one zero-valued element" so that callers can always read element zero. Because Length was never cleared on the out-of-range path, that fallback never fired for the one case it existed for. And the callers do read element zero, unconditionally: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip, and a dozen more take E.IntegerValues[0], and the strip tables do Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), copying Length times four bytes out of an array that has none. A cleared array with a live count is strictly more dangerous than an unchecked one, because the unchecked one at least holds the bytes it claims to
The second problem was ordering. The two SetLength calls ran before the range test, sized from the file's count, so a hostile entry could request a multi-gigabyte allocation before a single validity check. On Delphi the resulting exception was caught by a handler further up the image-loading path and the file simply failed to load, which is why nobody noticed; what actually happened was an out-of-memory event the file chose. The fix moves the allocation after the test and makes the count travel with the data
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // the count goes with the values
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // only now
SetLength(Result.DoubleValues, Result.Length);
End;
// ... later, the existing fallback finally reaches the case it was for:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Nothing about this fix is compiler-specific, which is what makes it belong in this list. The defect was latent on Delphi for the same reason it was latent on Free Pascal: no test file had a directory entry pointing past the end of the file. The port did not expose it. Reading the code with the question "what does Delphi do for me here that I am not doing myself" did
What happens when a PNG IHDR claims a color type the format does not define?
PDF Library for Delphi now rejects the image before the row filters run; before v3.539.2 it computed a zero-byte scanline and handed the unfilter loops an empty buffer. ISO 15948 §11.2.2 defines the IHDR chunk and Table 11.1 lists the six legal color type and bit depth combinations: grayscale at 1, 2, 4, 8, or 16 bits, indexed color at 1, 2, 4, or 8, and truecolor, grayscale with alpha, and truecolor with alpha at 8 or 16. TPNGReader validated the compression method and the filter method fields of IHDR and passed FColorType and the bit depth through untouched
The row filter code sizes everything from a Case FColorType Of that maps each color type to a component count. A color type outside the six falls into the Else branch, where SourceComponents is 0, so ScanlineByteCount is 0, so SetLength(PreviousScanline, 0) is followed immediately by FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indexing element zero of an empty dynamic array is an address computed from nil. With range checking off, a zero-byte fill through that address is a silent no-op and the decoder marches on through rows that do not exist; with range checking on it is an ERangeError on the first image; and the Move calls that follow it are a step away from an access violation. Which of those you get depends on the compiler and on build switches rather than on anything the decoder decided, and that is the tell that the decoder never decided at all
The fix is the table from the specification, applied where the other IHDR fields were already being checked: COLOR_GRAYSCALE accepts FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE accepts [1, 2, 4, 8], and COLOR_RGB, COLOR_GRAYSCALEALPHA, and COLOR_RGBALPHA accept [8, 16]; anything else clears ValidImage and the image is refused with its width and height intact for diagnostics. A pHYs chunk shorter than its nine bytes was closed in the same pass, since the DPI reader indexed S[1] through S[8] of a string the short chunk had left empty
A 1-based offset treated as a 0-based pointer
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString takes a 1-based StartPos, because its input is an AnsiString and the Delphi implementation addresses the zlib input as @Input[StartPos]. The Free Pascal implementation, written against paszlib so both Windows targets link compression statically, set next_in to PAnsiChar(Input) + StartPos and avail_in to Length(Input) - StartPos. That is pointer arithmetic, and it is 0-based. Pass 1, which is what "start at the beginning" means for this function, and the FPC build starts inflating at the second byte and stops one byte before the end
The reason it survived is that the only caller most tests reach is InflateStr, which passes 0. Zero happens to be the correct 0-based offset, so the two builds agreed on every plain InflateStr call and every test that went through it. TPDFDocument.DecodeAllStreams, the routine SaveQDFToFile and ConvertFileToQDF use to expand single-FlateDecode streams into readable form, passes 1. On the FPC build the skipped zlib header made the inflate fail, but the zlib stream still reported a nonzero Consumed for the bytes it had examined, so DecodeAllStreams took the empty payload as a successful decode and replaced every content stream with an empty string. The resulting QDF had the right page count, valid structure, and no page content, which is a file that opens without an error in every viewer and shows nothing
// FPC branch of InflateStrFromPosition, after v3.539.16.
// StartPos is 1-based like the Delphi branch; clamp it, then convert
// to a 0-based pointer offset exactly once, at the boundary.
If (StartPos < 1) Then
StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
Exit;
...
strm.next_in := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;
The regression that guards it is the smallest possible one: deflate a payload, inflate it from position 0 and from position 1, and assert both return the same payload and both report Consumed equal to the full stream length. An RFC 1950 stream has a two-byte header and a four-byte Adler-32 trailer, so an off-by-one at either end is not a subtle corruption, it is a stream that either fails to start or fails to finish. The lesson is about the boundary, not zlib: when a function's parameter is defined in one index base and the implementation underneath uses the other, the conversion belongs in exactly one line, and a test has to call it with the value that distinguishes the two bases
Why is a short TStream.Read not the end of the stream?
Because TStream.Read is allowed to return fewer bytes than requested for any reason it likes, and only a return of 0 means there is nothing more. TMemoryStream and TFileStream on a local disk almost always fill the request, which is why code that treats "returned less than I asked for" as end-of-file passes every test that uses them. Network-backed streams, decompression streams, and any TStream descendant a customer wrote can return two bytes when asked for sixty-four thousand and still have gigabytes behind them
TPLBuffer is the reader every parser in PDF Library for Delphi goes through, and it can wrap an AnsiString, a pointer, a byte array, or a TStream. Its four scanning queries, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte, and DistanceToOtherBytes, all returning Int64, read the source in 64 KB blocks looking for a delimiter and report how far away it is without moving the logical position. Each loop ended with Until ReadCount < BlockSize. For the three in-memory sources that is correct, since ReadIntoBuffer always delivers the full block until the last one. For the stream source it means the scan gives up on the first short read, reports the delimiter as absent, and the tokenizer above it decides the object ends where it does not
// TPLBuffer.DistanceToByte, the loop after v3.539.6.
// Zero is the only end-of-data signal TStream.Read defines.
TempPosition := FPosition;
Try
Repeat
ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
For TestPos := 0 To ReadCount - 1 Do
If TempBuffer[TestPos] = Value Then
Begin
Result := TotalSkipped + TestPos;
Exit;
End;
Inc(TotalSkipped, ReadCount);
Until ReadCount = 0;
Finally
FPosition := TempPosition; // a peek must not move the reader
End;
The test that pins this down is a TMemoryStream descendant whose Read override caps every request at two bytes. Wrap the string aaaaaX in it, set the buffer position to 1, and all four queries must report a distance of 4 to the X, leave the position at 1 afterward, and report -1 for a byte that is not there. Before the fix the first query saw two bytes, concluded the stream was exhausted, and returned -1. The finally matters as much as the loop condition: an Exit from inside the scan is the normal success path, and the logical position has to be restored on that path too, not only when the loop runs to completion
One source, two compilers, one set of assertions
The discipline that fell out of these five is that "the Delphi build passes" is evidence about Delphi, not about the source. Since v3.539.16 the Delphi DUnitX suite and the Free Pascal console suite both include the same Tests\CrossCompilerSemantics.inc, a single routine, RunCrossCompilerFileSemantics, that builds a two-page document with compressed content through TPDFlib, saves it, saves it again as QDF through SaveQDFToFile, repairs the QDF with RepairQDFFile, encrypts the plain file with AES-128 through EncryptFile and a permission mask from EncodePermissions, and then reloads every artifact and asserts the same things on both compilers: page count is 2, the title survives, page two's text extracts intact from the plain, repaired, and encrypted files, the wrong password is refused with a nonzero LastErrorCode, EncryptionStrength is 128, EncryptionAlgorithm is 2, and the individual permission bits from GetUserPermissions come back exactly as encoded
The comparison is deliberately normalized rather than byte-for-byte. Encryption draws random salts and the writer assigns document identifiers, so the two builds are not expected to emit identical files; they are expected to emit files that mean the same thing, and the assertions are phrased at that level. The QDF leg is there specifically because of the offset bug: a QDF with two pages and no content passes a page-count check and fails a text-extraction check, and the matrix asserts the second one. Any future fix that is a no-op on one compiler and a behavior change on the other, which describes four of the five above, now has to clear the same assertions twice before it ships
The link-time half of the same port, getting Delphi's OMF objects and Free Pascal's COFF expectations to agree, is its own story in FPC Win32 OMF to COFF object linking, and the structural hardening of the same TIFF reader against BigTIFF and tiled files is in the built-in TIFF decoder notes. The decoders in this article, and the cross-compiler test that now sits underneath them, ship in the PDF Library for Delphi for Delphi, C++Builder, and Free Pascal, where the same source is expected to earn the same result on every compiler it targets rather than be granted it by one