Technical Article

PDF Page Labels in Delphi: Fixing /Kids Number Trees

You add a new label range to a vendor manual so the appendix reads App-a, App-b instead of A-1, A-2, save, and reopen the file. The page box still says A-1. Open the catalog in a text editor and the /PageLabels dictionary shows the reason: it carries a /Kids array inherited from the producer and, right next to it, a fresh /Nums array holding your range. ISO 32000-1 lets a number tree root have one of those keys or the other, and a reader that finds /Kids on the root descends into it and looks no further

How a PDF stores page label ranges

Page labels are the strings a viewer shows in its page box instead of the physical page index, and ISO 32000-1 §12.4.2 keeps them in a number tree under the catalog key /PageLabels. Each key is a 0-based page index where a labelling range begins. Each value is a label dictionary with up to three entries: /S for the numbering style (D, R, r, A or a), /P for a prefix string, and /St for the number of the first page in the range, which defaults to 1. A range runs until the next key. The specification also requires an entry for page index 0, so every page in the document falls inside some range

Page label storage in PDFlibPas terms: the /PageLabels number tree keys each range by its zero-based start page, every value is a label dictionary with /S style, /P prefix and /St first number, and the book example maps roman front matter, arabic body pages and an A- appendix onto three ranges
A range runs until the next key, the specification requires a value for page index 0, and GetPageLabel applies the last range whose key is at or below the page, so every page resolves to something

In PDF Library for Delphi the writer is TPDFlib.AddPageLabels(Start, Style, Offset, Prefix), and it maps onto that dictionary with three rules worth memorising. Start is 1-based like every other page argument in the library, and it goes into the tree as Start - 1. Style runs from 0 to 5: 0 writes no /S at all, so the label is the prefix alone (handy for a single page labelled Cover), and 1 to 5 become D, R, r, A and a. A style outside 0..5 makes the call return 0 and leaves the document untouched. Offset becomes /St only when it is greater than zero; pass 0 and the key is omitted, which every reader interprets as 1

A typical book from a layout tool needs three ranges: lower-case roman numerals for the front matter, arabic numbers for the body, and a prefixed appendix

uses
  PDFlibrary;

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Pages 1-4: i, ii, iii, iv (lowercase roman)
    Lib.AddPageLabels(1, 3, 1, '');
    // Pages 5-120: 1, 2, 3 ... (decimal, restarting at 1)
    Lib.AddPageLabels(5, 1, 1, '');
    // Pages 121 onward: A-1, A-2 ... (decimal with a prefix)
    Lib.AddPageLabels(121, 1, 1, 'A-');
    WriteLn(Lib.GetPageLabel(5));    // 1
    WriteLn(Lib.GetPageLabel(122));  // A-2
    Lib.SaveToFile('handbook-labeled.pdf');
  finally
    Lib.Free;
  end;
end;

Page labels arrived in PDF 1.3, so each call also asks the document to raise its output version to at least 1.3. If you pinned the version with LockSaveVersion, that request is ignored and the lock holds, because the library does not overrule a version you chose on purpose. If you lock at all, lock at 1.3 or higher in any job that writes labels

Why does a new range vanish when the root has /Kids?

The range vanishes because ISO 32000-1 §7.9.7 (Table 37) gives the root of a number tree either /Kids or /Nums, never both, and readers pick /Kids when it is present. Producers that write long documents often split the tree into intermediate nodes, each with a /Limits pair, and hang them off a root that has only /Kids. An insert routine that only knows about /Nums looks at that root, finds no array, creates one beside the existing /Kids, and drops the new range in. The write succeeds. The file is still parseable. Every viewer then descends /Kids, sees the producer's original three ranges, and never touches the stray array, which is exactly the vendor-manual symptom from the opening paragraph

The way out is to stop treating the root as a container that might grow a second entry point. When AddPageLabels finds /Kids on the root, it first walks every leaf of the existing tree from left to right and collects each key and its label dictionary, then builds one flat /Nums array from that list. /Kids, /Limits and any leftover /Nums are removed from the root before the flat array is attached, and only then does the new range go in with an ordinary sorted insert. Removing /Limits matters too: Table 37 allows it on intermediate and leaf nodes only, and a root that keeps it is malformed even if it now holds /Nums

Number tree repair in PDFlibPas: a root that carries /Kids and a stray /Nums array is invisible to viewers because ISO 32000-1 allows only one of the two, so NumTreeSet flattens every leaf into a single /Nums array and purges /Kids and /Limits, which Table 37 never allows on a root
A stray /Nums next to /Kids passes every surface check: the insert succeeds and the saved file opens cleanly, yet a reader that descends /Kids first, the way viewers and the library itself both do, never finds the new range

One side effect is easy to miss. If a file reaches you already broken, with both keys on the root because some earlier tool made the naive insert, the leaf walk follows /Kids just as a viewer would, so the stray array's ranges are not carried over. A viewer never showed those ranges either, so the labels after the edit match what readers already saw

The flattened tree is not rebuilt into balanced /Kids nodes afterwards, and we think that is the right call for this particular tree. Balancing pays off for name trees with thousands of destinations or embedded files. A page label tree has one entry per labelling range, and even a large reference manual rarely needs more than a few dozen; a single leaf is also what most producers write when they are not splitting for its own sake. A rebalancing pass would add code that can go wrong on every save, for a lookup gain nobody could measure on a tree that small

// Relabel the appendix in a file whose /PageLabels root uses /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // e.g. A-1
  // Same start page as the existing range, so its value is replaced
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
  begin
    WriteLn('After: ', Lib.GetPageLabel(121));  // App-a
    WriteLn('Front: ', Lib.GetPageLabel(2));    // ii, unchanged
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  end;
end;

Because the new range starts on the same page index as an existing key, the insert replaces that key's dictionary instead of adding a second entry for page index 120. The roman and decimal ranges from the producer's leaves come through with their original dictionaries, including any /St values

Walking the leaves has to survive malformed trees as well. A /Kids entry that points back to one of its own ancestors, or two branches that share a leaf, turns up in files from damaged pipelines. The traversal remembers which nodes it has already visited, so a cycle is walked once and then left behind, and a shared leaf contributes its ranges once. The same concerns for name trees are covered in handling name tree cycles, bad /Limits and large leaves

Walking a /Nums array in pairs

A /Nums array is a flat run of alternating entries, [key0 value0 key1 value1 ...], and only the even positions are keys. That layout invites a classic mistake. A loop that steps one element at a time and asks 'is this a number?' will compare values as if they were keys whenever a value happens to be numeric. If that comparison wins a less-than test, the insertion point lands on an odd index, the new pair goes into the middle of an existing one, and every later key and value swap places

Pair stride in PDFlibPas number trees: a /Nums array is a flat run of alternating key and value entries, so a walk that tests every element can insert a new pair at an odd index and shift later pairs out of phase, while a pair walk reads the key at X*2 and the value at X*2+1
A single-step walk rarely misfires on /PageLabels because label values are dictionaries, but a number-tree helper that reads the wrong stride corrupts the moment any value is numeric, so both walks step in pairs

Page label values are dictionaries, so the bug hides on /PageLabels itself. It surfaces the day the same helper is reused for a number tree whose values are plain numbers, and nothing in the array's syntax warns you which kind you are holding. PDF Library for Delphi reads keys at index X * 2 and values at X * 2 + 1 in both the leaf walk and the insert, and an exact key match replaces the value and stops the scan. If you write your own number tree code against raw objects, copy that stride before anything else

Reading labels back with GetPageLabel

TPDFlib.GetPageLabel(Page) returns the label for a 1-based page, and its two fallbacks behave differently on purpose. A document with no /PageLabels entry at all returns the decimal page number, so a caller can use the function unconditionally for display. A document that has a tree but no range covering the requested page returns an empty string. That second case is what a file produces when it skips the mandatory entry for page index 0: the first few pages have no label in the specification's sense, and an empty string reports that more honestly than a made-up number would. Always start your label set at page 1

Letter styles follow the PDF specification rather than spreadsheet columns. After Z comes AA, then BB, then CC; the letter repeats instead of carrying, so page 53 of an A range is AAA. Roman numerals are generated in upper case for R and lower case for r, and the prefix is prepended unchanged in both cases

var
  P: Integer;
  Data: WideString;
begin
  // Quick audit of what a viewer will show in its page box
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // Option value 4 exports only the label ranges, one PageLabelBegin
  // block per range
  Data := Lib.ExportDocumentData(4);
  // ... edit the text, or carry it to another file ...
  // The first label record clears existing ranges, then each record
  // is replayed through AddPageLabels; returns the records applied
  WriteLn(Lib.ImportDocumentData(Data, 4), ' label ranges applied');
end;

For bulk edits, ExportDocumentData with option value 4 writes each range as a PageLabelBegin block followed by PageLabelNewIndex (the 1-based start page), PageLabelStart, an optional PageLabelPrefix and PageLabelNumStyle lines, using names such as LowercaseRomanNumerals and DecimalArabicNumerals. ImportDocumentData treats the first label record it meets as a full replacement: it calls ClearPageLabels once, which removes the whole /PageLabels entry from the catalog, and then feeds every record to AddPageLabels. The rebuilt tree is a single leaf from the start, so a text round trip through a /Kids-shaped original comes back in the same flat form the relabelling path produces

Leaf order and page moves after you relabel

Flattening keeps the ranges in the order it finds them. §7.9.7 requires keys in ascending order, but files with out-of-order leaves do circulate, and GetPageLabel applies the last range in that order whose key is at or below the page index. For a file that declares its ranges as [0 roman, 120 A-, 4 decimal], with the decimal range starting at 1, page 122 resolves to the decimal range and reads 118 where you expected A-2. If the page-by-page audit above shows labels like that, do not patch around them with one more AddPageLabels call. Call ClearPageLabels and write the full set again in ascending order; four calls are cheaper than reasoning about which range wins

Labels are also bound to page indices, not page objects. Anything that changes the page count or order leaves the ranges exactly where they were, pointing at whatever pages now sit at those indices. An in-place swap such as replacing pages while preserving object numbers keeps the count, so the labels stay aligned. A merge like collating interleaved duplex scans produces a new page sequence, and that output should get a freshly written set of ranges after the merge, starting at page 1

The label calls, the /Kids flattening and the document data export and import shown here are part of PDF Library for Delphi, which targets Delphi, C++Builder and Lazarus; its reference entry for AddPageLabels lists the style values and return codes in one table