PDFlibPas, losLab's PDF library for Delphi and C++Builder, speeds up its rendering and content-generation paths by replacing four repeated-work patterns with amortized ones: a lazy hash index for dictionary key lookups, a precomputed sRGB gamma lookup table, first-byte bucketing for content-stream operator dispatch, and TStringBuilder in place of repeated string concatenation. None of the four came from one dramatic discovery — they came from the same unglamorous pattern in a profile: a small function called once per operator, once per pixel, or once per character, where a linear cost inside the call becomes quadratic or near-quadratic across a whole document. That is the throughline here: four small, unrelated-looking fixes that attack the same shape of problem, plus the honest limits of each one
Where a Content-Stream Renderer Actually Spends Its Time
PDFlibPas's content-stream renderer funnels almost all of its per-token cost through four narrow points: resource dictionary lookups on /Resources, /ColorSpace, /Font, and /ExtGState; gamma correction on every decoded pixel of a Lab, Indexed, or ICC-tagged image; operator-name matching on every token of every content stream; and string construction wherever the library builds output — literal string escaping on save, XFDF export, stamp and variable token expansion. Each of the four does a small amount of work on its own, and each one runs thousands or millions of times over a realistic document, which is exactly the shape of function where an O(n) or O(n²) implementation detail stops being invisible and starts being the profile's top entry
Why Do Resource Dictionary Lookups Get Slow in a Large PDF?
TPDFDictionary.FindIndexByKeyName is what the renderer calls to resolve every /Resources, /ColorSpace, /Font, and /ExtGState lookup, and it used to walk the Entries array from the front on every call — fine for a three-entry Resources dictionary, expensive for a Form XObject or an ExtGState-heavy page where the same dictionary gets probed on every operator that touches color or graphics state. PDFlibPas now builds a lazy hash index once a dictionary passes DICT_HASH_THRESHOLD (16) entries and leaves smaller dictionaries on the linear scan, since most PDF dictionaries never get that large and a hash table for three keys would cost more to build than it saves. The index is a flat open-addressing table keyed by PLAnsiStringHash, an FNV-1a hash with the canonical offset basis 2166136261 and prime 16777619, chosen to avoid pulling in System.Generics.Collections for something this size-sensitive
Const
DICT_HASH_THRESHOLD = 16;
Function TPDFDictionary.LookupKeyIndex(Const Key: AnsiString): Integer;
Var
H, Probe: Integer;
Begin
Result:= -1;
If FKeyHashMask= 0 Then
Begin
// Not built yet; small dictionaries stay linear since the
// build cost would not amortize over a handful of entries.
If Length(Entries)> DICT_HASH_THRESHOLD Then
BuildKeyHash
Else
Exit;
End;
H:= PLAnsiStringHash(Key) And FKeyHashMask;
Probe:= 1;
While FKeyHash[H]<> -1 Do
Begin
If Entries[FKeyHash[H]].Key.Name= Key Then
Begin
Result:= FKeyHash[H];
Exit;
End;
H:= (H+ Probe) And FKeyHashMask;
Inc(Probe);
End;
End;
The index is invalidated rather than incrementally maintained: every mutating call — AddEntry, DeleteEntryByKeyName, Assign, AddDict — clears the hash and lets the next lookup rebuild it from scratch. That looks wasteful until you notice that a dictionary key is a TPDFName object, and TPDFName.SetTo can rename a key already sitting in a dictionary's Entries array without going through any of the dictionary's own methods — an incremental index has no way to observe that rename, while a lazy one just rebuilds and stays correct by construction. The price of that safety is an O(n) rebuild the first time a large dictionary is queried after a write, plus the memory for the hash table itself, roughly one Integer per slot at a two-thirds load factor — a rounding error for the handful of oversized dictionaries in a typical document, and a real cost PDFlibPas avoids paying on every small one by keeping the threshold where it is
Precomputing sRGB Gamma Instead of Calling Power Per Pixel
TPDFSimpleColorManager.XYZ2RGB applies the sRGB transfer function to every decoded pixel of a Lab, Indexed, or ICC-based image — 1.055 * Power(x, 1/2.4) - 0.055 above the linear-segment threshold — and Power(x, y) for a fractional y has no cheap closed form on the Pascal RTL: it decomposes into Ln(x) then Exp(y * Ln(x)), and that pair of transcendental calls, run three times per pixel for the red, green, and blue channels, is the dominant cost of decoding a Lab or ICC image pixel by pixel. PDFlibPas replaces the three per-pixel Power calls with one lookup into GSRGBGammaLUT, a 4096-entry Double array built once via EnsureSRGBGammaLUT and indexed by rounding the clamped input to the nearest slot
Const
SRGB_GAMMA_LUT_SIZE = 4096;
Var
GSRGBGammaLUT: Array [0..SRGB_GAMMA_LUT_SIZE- 1] Of Double;
GSRGBGammaLUTReady: Boolean= False;
Procedure EnsureSRGBGammaLUT;
Var
I: Integer;
X: Double;
Begin
If GSRGBGammaLUTReady Then
Exit;
For I:= 0 To SRGB_GAMMA_LUT_SIZE- 1 Do
Begin
X:= I/ SRGB_GAMMA_LUT_SIZE;
If X> 0.0031308 Then
GSRGBGammaLUT[I]:= 1.055* Power(X, 1/ 2.4)- 0.055
Else
GSRGBGammaLUT[I]:= 12.92* X;
End;
GSRGBGammaLUTReady:= True;
End;
Function SRGBGamma(X: Double): Double;
Var
Idx: Integer;
Begin
If X<= 0 Then
Result:= 0
Else If X>= 1 Then
Result:= 1
Else
Begin
Idx:= Round(X* SRGB_GAMMA_LUT_SIZE);
If Idx> SRGB_GAMMA_LUT_SIZE- 1 Then
Idx:= SRGB_GAMMA_LUT_SIZE- 1;
Result:= GSRGBGammaLUT[Idx];
End;
End;
A 4096-slot table over the [0, 1] input range gives roughly sixteen times the resolution of an 8-bit output channel, so the quantization the LUT introduces sits below what the final RGB byte can represent — table lookup replaces transcendental math here without a visible precision cost. The same reasoning shows up next to it in Lab2XYZ, where Power(LMN[i], 3) became a plain LMN[i]*LMN[i]*LMN[i]: an integer power does not need Ln/Exp in the first place, so that one is not a LUT trade-off at all, just a redundant Power call removed. The LUT trick only pays off because the transfer function is a pure function of a single Double — it would not extend cleanly to a color transform that depended on several pixel values or on more state than that
How Do You Dispatch 73 Content-Stream Operators Fast?
ContentOperatorFromName is called once for every token PDFlibPas reads out of a content stream, matching it against ISO 32000-1 Table 51's full set of 73 operators — from w and q up to the rarely-seen d0 and d1 Type 3 glyph-metrics operators — and it used to walk that list linearly on every single token, so a page with a few thousand operators meant a few thousand linear scans over the same 73-entry table. PDFlibPas now buckets the table by the operator's first byte at startup, into a fixed AnsiChar-indexed array of slots, so a lookup becomes one array index plus a scan of only the handful of operators sharing that first character
Type
TOpSlot= Record
Count: Integer;
Ops: Array [0..15] Of TPDFContentOperator;
End;
Var
GOpBuckets: Array [AnsiChar] Of TOpSlot;
GBucketsReady: Boolean= False;
Function ContentOperatorFromName(Const Name: AnsiString): TPDFContentOperator;
Var
Ch: AnsiChar;
Slot: ^TOpSlot;
I: Integer;
Op: TPDFContentOperator;
Begin
Result:= coUnknown;
If (Name= '') Then
Exit;
EnsureOpBuckets;
Ch:= Name[1];
Slot:= @GOpBuckets[Ch];
If Slot^.Count= 0 Then
Exit;
For I:= 0 To Slot^.Count- 1 Do
Begin
Op:= Slot^.Ops[I];
If (PDFContentOpInfo[Op].Name= Name) Then
Begin
Result:= Op;
Exit;
End;
End;
End;
PDF operators are case-sensitive — w and W, f and F, sc and SC are all different operators — so GOpBuckets keys on the raw byte and the residual compare inside a bucket is a plain, case-sensitive AnsiString equality. The array is sized at 16 slots per letter, which comfortably covers today's table — the busiest bucket, T, holds thirteen operators, since almost every text-state and text-positioning operator starts with it — but EnsureOpBuckets silently stops adding to a bucket once its count reaches 16, so a bucket that ever needed a fourteenth entry would fail silently rather than loudly: the operator would resolve to coUnknown with no exception pointing at why. That is the maintenance cost of trading a data structure that degrades gracefully for one that does not — it dispatches faster because it never needs a bounds-checked grow, and it needs a human watching the one bucket close to its ceiling
Cutting O(n²) Out of String Building
Pascal's Result := Result + Fragment pattern reallocates and copies the entire accumulated string on every iteration, so building an N-character output one fragment at a time costs O(n²) instead of O(n) — easy to miss in review, since each line looks like one cheap append, and expensive in practice because PLDirectEscapeLiteralString runs on every literal PDF string written during save and XFDFXMLEscape runs on every field value exported to XFDF. PDFlibPas fixes the two with different techniques, chosen by what each function can predict in advance. PLDirectEscapeLiteralString knows its output length before writing a single byte — one pass classifies each character as plain or escaped and sums the total, SetLength allocates once, and a second pass fills the buffer by index. XFDFXMLEscape cannot cheaply predict its output length, since Unicode field text varies too much to precompute, so it appends into a TStringBuilder pre-sized to roughly the input length instead
Function XFDFXMLEscape(Const W: WideString): WideString;
Var
I: Integer;
Builder: TStringBuilder;
Begin
// TStringBuilder avoids the O(n^2) WideString concatenation that
// XFDF export used to hit on every field value
Builder:= TStringBuilder.Create(Length(W)+ 16);
Try
For I:= 1 To Length(W) Do
Begin
Case W[I] Of
'&': Builder.Append('&');
'<': Builder.Append('<');
'>': Builder.Append('>');
// ...'"', tab, CR and LF cases follow the same shape
Else
Builder.Append(W[I]);
End;
End;
Result:= Builder.ToString;
Finally
Builder.Free;
End;
End;
The choice between the two is really about what you know before the loop starts. Count-then-fill is the faster of the two when the output size is cheap to compute, since it does zero reallocations and no bookkeeping beyond an Integer counter, but it means writing the classification logic twice — once to count, once to emit — which is its own maintenance risk if the two copies drift apart. TStringBuilder gives up a little of that peak throughput for writing the logic once and getting amortized O(1) appends from geometric buffer growth, which is the safer default whenever the output size is not easy to know upfront
Where This Pattern Applies, and Where It Does Not
All four fixes above are instances of one idea: find the call that runs once per unit of input — per dictionary key, per pixel, per operator token, per character — and replace its linear or unpredictable cost with a precomputed table, a hash index, or a pre-sized buffer. None of it is specific to PDF; a Delphi service that resolves the same lookup key thousands of times per request, converts values in a tight loop, dispatches on a fixed vocabulary of tokens, or builds long strings a character at a time hits the same failure shapes and takes the same fixes. What none of these four changes touch is concurrency or memory footprint: a faster single-threaded dictionary lookup does nothing for two threads racing on the same TPDFlib instance, which is a structural problem covered separately in the article on thread safety in parallel page rendering, and it does nothing for a PDF too large to load into memory as an object tree at all, which is what the Direct Access layer in PDFlibPas is for, covered in the article on merging and splitting gigabyte PDFs
The dictionary, color-management, content-stream dispatch, and string-building code discussed here ships as part of the standard PDFlibPas, losLab's PDF library for Delphi and C++Builder, with no extra configuration needed to get any of it