Technical Article

Delphi vs FPC: 4 Hidden PDF Code Pitfalls in PDFium Builds

The same Object Pascal source can behave differently under Delphi and FPC/Lazarus in four ways that repeatedly bite PDFium Component code: FPC disposes function-result record temporaries before a in membership test finishes reading them, dcc32 ships with range checking off so out-of-bounds array indexes read garbage silently, only Delphi 13 accepts assigning an anonymous array of Byte to TBytes without a cast, and Delphi's AnsiString concatenation can destroy bytes at or above $80 through a hidden code-page round-trip. Each one produces a suite that is green on one compiler and red, or worse, silently wrong, on the other

If you are setting up a dual-compiler project for the first time, the Lazarus and FPC viewer walkthrough covers the happy path: packages, search paths, and getting a rendering window on screen. This article is the opposite of a tutorial. It is the list of things we hit after the happy path worked, when CI was green under FPC, green under Delphi, and then a change that passed on one side detonated on the other. Every pitfall below comes from a real failure in the PDFiumPas test suite or its demos, with the commit-level forensics condensed into a minimal reproduction, the root cause, and the fix we standardized on

Why does a set read back empty under FPC but not in Delphi?

The one-sentence version: FPC may finalize the temporary variable holding a function's record result before an expression that reads a field of that result has finished, so X in Func().Issues can test membership against an already-released set while the equivalent Delphi expression works. Our PDF/E conformance tests hit this in their first version. The validator returns a record whose Issues field is a set of violation flags, and the assertions inlined the call

// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Reliable on both compilers: pin the result to a local first
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

The inlined form read the set as empty under FPC, so every assertion that expected a flag failed, while the identical Delphi build passed. The root cause is a difference in how the two compilers manage the lifetime of function-result temporaries inside larger expressions: Delphi keeps the temporary alive to the end of the statement, whereas FPC's disposal of the record temporary can race the set-membership operator that is still reading it. We had already documented the same behavior once before, in a comment on the FlagPresent helper in the PDF/A test unit, and then reintroduced the bug anyway when writing new tests from scratch, which tells you how natural the broken form looks. The fix is mechanical and worth adopting as a blanket rule: never chain a field access or set test directly onto a function call that returns a record; assign the result to a local variable first, then read the field. It costs one line and removes an entire class of compiler-dependent flakiness

Why does Delphi accept an array index that FPC refuses to compile?

The one-sentence version: dcc32 compiles an out-of-range index into a fixed-bounds array and, with its default range checking disabled, reads or writes adjacent memory at runtime without any error, while FPC rejects the same index at compile time. The PDFium Component declares quad points as a 1-based array, TQuadrilateralPoint = array [1..4] of TPdfPoint, matching how PDF's QuadPoints entries are usually numbered. A demo that filled it with the reflexive 0-based loop worked for months under Delphi

var
  I: Integer;
begin
  for I := 0 to 3 do                       // wrong: the array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
                                           // silently touches adjacent memory
                                           // FPC: compile-time range check error
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct on both compilers
end;

The Delphi build was a false positive: with range checking off, which is the dcc32 default, index 0 landed on whatever field precedes the array in the record, and the demo appeared to run. Porting the same demo to Lazarus produced an immediate compile-time range check error from FPC, and fixing the index then exposed a second, deeper bug in the library's annotation path that the garbage reads had been masking, the one dissected in the quad-points annotation article. Two lessons came out of that incident. First, prefer Low() and High() over literal bounds whenever the array type is not 0-based by construction. Second, treat an FPC compile, or at minimum one Delphi build with {$R+} enabled, as a mandatory first-run gate for any new demo or test: dcc32's defaults will not tell you about this class of bug, and a program that runs is not evidence that it is correct

The TBytes assignment that only Delphi 13 accepts

The one-sentence version: assigning a field declared as anonymous array of Byte to a TBytes variable compiles on Delphi 13 (compiler version 37.0) but fails on Delphi 12 Athens and every earlier release with E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. This one is not a Delphi-versus-FPC split so much as a Delphi-versus-its-own-past split, but it bites the same multi-compiler codebase in the same way: the newest compiler quietly accepts a construct that everything else rejects

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymous dynamic array type
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Delphi 13 only; E2010 on Delphi 12
                                 // Athens and earlier
  OrigBytes := TBytes(FBuffer);  // compiles everywhere; same byte layout,
                                 // safe hard cast
end;

We shipped exactly this in a validation routine, developed and tested locally on Delphi 13, where the implicit conversion was silently accepted. The full-source installer serves users on Delphi 12 and older in large numbers, and for them the unit simply did not compile. The structural fix is either the hard cast shown above, which is safe because an anonymous array of Byte and TBytes share an identical dynamic-array layout, or better, declaring the field as a named type such as TBytes in the first place so no conversion ever arises. The process fix matters more: a construct that compiles on your newest toolchain proves nothing about the older compilers your users actually run, and this category of regression is invisible until you build against every supported version. Our release scripts now compile the library across the full compiler matrix precisely because a local 37.0 build cannot catch a 13-only leniency

The AnsiString byte that vanishes on a Chinese Windows machine

The one-sentence version: concatenating a raw byte at or above $80 into an AnsiString with + can silently replace that byte with ? ($3F) under Delphi, because the expression takes an implicit AnsiString to UnicodeString to AnsiString round-trip through the system code page. We found this through a PDF/A test that constructs a name containing an isolated $FE byte, which is never a legal UTF-8 lead byte, to verify the validator flags names that are not valid UTF-8 per ISO 19005-2 clause 6.1.8

var
  BadName: AnsiString;
begin
  // On Delphi with a multi-byte system code page (observed on CP936),
  // the concatenation round-trips through UnicodeString and $FE, which
  // is not a valid CP936 sequence, comes back as '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Safe: build with an ASCII placeholder, then patch the byte in place;
  // indexed assignment into a settled AnsiString does not round-trip
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

On a Chinese Windows system running code page 936, the concatenated string never contained $FE at all, so the library correctly reported nothing and the test went red while looking like a library bug. The library was never wrong: an FPC harness that fed in a PDF genuinely containing the $FE byte got the expected flag. The corruption happened inside the Delphi test executable while the string expression was being evaluated, because Delphi's Unicode-first string model converts mixed AnsiString expressions through UnicodeString, and $FE is not a valid lead byte in CP936 so the round-trip replaces it. Be honest about the boundary here: on a single-byte Western code page such as CP1252 the same expression usually survives, which is exactly why this bug hides on most development machines and surfaces only on East Asian systems or localized CI runners. The rule we adopted: never build binary test vectors containing bytes at or above $80 by AnsiString concatenation; either patch bytes in place after the string is settled, as above, or construct the vector in TBytes from the start

What a dual-compiler workflow should check by default

Four pitfalls, one pattern: each compiler tells you about a different subset of your bugs. FPC's compile-time range analysis caught an out-of-bounds index that dcc32 ran silently for months, and dcc32's Unicode string model exposed a code-page dependency that a pure byte-oriented FPC build never triggers. The practical consequence is that neither green pipeline is sufficient alone. Cross-compiling is not just a portability checkbox, it is a second static analyzer and a second runtime model applied to the same source, in the same spirit as the defensive boundary checks in the ABI and memory-safety hardening article

The standing rules that fell out of these incidents are short enough to memorize. Pin function-result records to a local variable before reading fields. Iterate fixed-bounds arrays with Low() and High(), and run at least one range-checked or FPC build before trusting any new demo. Cast anonymous dynamic-array fields explicitly, or declare them with named types, and build the full compiler matrix before release. Keep raw high bytes out of AnsiString concatenation entirely. None of these cost measurable effort once they are habits, and each one closes a failure mode that a single-compiler workflow structurally cannot see

All four issues were found and fixed in the course of maintaining the PDFium Component, which ships the same Object Pascal source for Delphi, C++Builder, and FPC/Lazarus and runs its conformance and regression suites on every one of those toolchains, so the pitfalls in this article are guarded by tests rather than by memory