Technical Article

What Does a PDF Look Like Inside? An Annotated Example

Drag a PDF into a text editor and you see a %PDF-1.7 line, keywords such as obj, endobj and stream, and long runs of binary noise. Inside, every PDF is a set of numbered objects plus a byte-offset xref table and a trailer; the noise is compressed data, which the HotPDF Delphi Component can leave uncompressed for reading

The clearest PDF structure example is a file small enough to read in one sitting. Below is a complete one-page PDF, 610 bytes, that prints "Hello, World!" in 24-point Helvetica. Save it as hello.pdf in UTF-8 without a byte order mark and with LF line endings, and it opens in any reader. The offsets are exact for that encoding: a script measured each object position, and the result passed qpdf --check (qpdf 12.3.2) and pdfinfo, while MuPDF 1.28.5 extracted "Hello, World!" from page 1 without a single warning

%PDF-1.7
%âãÏÓ
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page
   /Parent 2 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F1 4 0 R >> >>
   /Contents 5 0 R
>>
endobj
4 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
5 0 obj
<< /Length 44 >>
stream
BT /F1 24 Tf 72 720 Td (Hello, World!) Tj ET
endstream
endobj
xref
0 6
0000000000 65535 f 
0000000019 00000 n 
0000000068 00000 n 
0000000125 00000 n 
0000000263 00000 n 
0000000333 00000 n 
trailer
<< /Size 6 /Root 1 0 R >>
startxref
427
%%EOF

The header, and why encoding moves every offset

%PDF-1.7 is the file signature, a comment as far as the syntax goes, from which readers take the version. The second line is a comment too: ISO 32000-1 §7.5.2 says a file containing binary data shall follow the header with a comment of at least four bytes whose codes are 128 or above, so that transfer tools treat the file as binary. It is also the line where an editor silently changes the bytes. Saved as UTF-8, âãÏÓ becomes eight bytes (C3 A2 C3 A3 C3 8F C3 93), still all above 127, and the listing assumes exactly that. We saved the same text as Windows-1252, one byte per character, and every object moved four bytes toward the start of the file while startxref still said 427. qpdf answered "file is damaged" and "xref not found" at offset 427, then rebuilt the table by scanning; MuPDF printed "trying to repair broken xref" and still extracted the text. CRLF line endings broke the offsets the same way. A PDF is a byte format that happens to contain text, so treat it with a hex editor's habits

What does 1 0 obj ... endobj mean?

1 0 obj opens an indirect object and endobj closes it. ISO 32000-1 §7.3.10 defines the pattern: object number, generation number, the keyword obj, a value, and endobj. The object number is the identity of the object across the whole file; the generation number is 0 in a freshly written file and rises only when an edit reuses a deleted number. Object 1 holds a dictionary, written between << and >> as pairs of name keys and values (§7.3.7). Names start with a slash, like /Type and /Catalog, and are compared byte for byte (§7.3.5). The /Type entry is the type code that says what a dictionary is: a catalog here, a page tree node in object 2, a page in object 3, a font in object 4. 2 0 R is an indirect reference, "object 2, generation 0, wherever it is stored", which lets the catalog point at the page tree without containing it

Follow the references and the objects form a tree. The catalog names object 2 as /Pages; object 2 lists /Kids [3 0 R] and /Count 1; the page in object 3 points back with /Parent 2 0 R, sets a /MediaBox of 612 by 792 points (US Letter), and names its drawing instructions with /Contents 5 0 R. Its /Resources value is a direct object, a dictionary written in place, mapping the resource name /F1 to font object 4. That font is /Helvetica, one of the 14 standard fonts every conforming reader supplies itself (§9.6.2.2), so no font program needs embedding. Physical order does not matter: a writer could put the font first and the catalog last, and only the xref offsets would change

Reading the content stream: BT /F1 24 Tf 72 720 Td

Object 5 is a stream, a dictionary followed by raw bytes between the keywords stream and endstream. ISO 32000-1 §7.3.8.1 makes /Length the byte count from the line ending after stream up to, but not including, the line ending before endstream; here that is 44, the exact length of the one line of drawing code. That line stays on one line on purpose. Spread over five lines, its length would depend on whether your editor saves LF or CRLF, and a wrong /Length is the classic way a hand-typed PDF breaks

The drawing code is postfix: operands first, operator last. BT and ET open and close a text object (§9.4.1). /F1 24 Tf selects the font the page resources call /F1, at 24 points. 72 720 Td moves the text position to x = 72, y = 720 in user space, where the origin is the bottom-left corner and one unit is one point, so the text starts an inch from the left edge and ten inches up. (Hello, World!) Tj paints the string. Swap 720 for 72 and the greeting drops to the bottom of the page. Point Tf at a name the resources do not define and the file is in error, yet MuPDF 1.28.5 still extracted the string without complaint, a reminder that a clean result in one viewer proves little. Fonts, encodings and the other text operators are covered in PDF fonts and text, with code examples

The xref table, trailer, startxref and %%EOF

The cross-reference table maps each object number to the byte where its N G obj header starts. 0 6 means six entries, beginning at object 0, and ISO 32000-1 §7.5.4 fixes every entry at 20 bytes: ten-digit offset, space, five-digit generation, space, a one-letter type code (n in use, f free) and a two-byte line ending, which with LF is a space plus the line feed. That is why each row ends in a trailing space. Entry 0 is always free with generation 65535. Many editors strip trailing spaces on save, leaving 19-byte rows; qpdf 12.3.2 and MuPDF 1.28.5 both accepted that copy without a warning, which surprised us, but a parser that finds rows by arithmetic will not. Below the table, the trailer gives /Size 6, one more than the highest object number, and /Root 1 0 R, the catalog; startxref gives 427, the offset of the xref keyword; %%EOF ends the file. Readers start from that tail. The full reading order, the eight object types and incremental updates are in PDF file structure: header, body, xref and trailer, and what a viewer does once it has the objects is in how a PDF actually works when a reader opens it

Why does a real PDF look like gibberish in a text editor?

Real PDFs are unreadable because nearly everything in them is compressed, and since PDF 1.5 the objects and the xref table can be too. Content streams, fonts and images usually carry /Filter /FlateDecode, which is zlib deflate (§7.4.4). Object streams (§7.5.7) pack small objects into one compressed /Type /ObjStm stream, so their N G obj headers disappear. A cross-reference stream (§7.5.8) replaces the text table with binary rows whose type codes are numbers: 0 free, 1 an object at a byte offset, 2 an object inside an object stream. We saved the same Hello World page from HotPDF with compression, object streams and an xref stream switched on: 1,166 bytes, and only three object headers left in sight, namely the compressed content stream, an object stream with /N 6 holding the other six objects, and the xref stream that startxref pointed at. qpdf turns such a file back into something readable. --qdf decompresses streams, prints one dictionary entry per line, renumbers objects in page order and labels each with %% Original object ID; --object-streams=disable unpacks the object streams and writes a text xref table. Our 1,166-byte file came back as 2,562 bytes with eight objects, each with its own header, from this one command:

qpdf --qdf --object-streams=disable packed.pdf readable.pdf

Listing objects and checking xref offsets from Delphi

Finding where each header starts and whether the xref table agrees, the two checks this article has done by eye, takes a page of plain Object Pascal and no PDF library. The first routine scans raw bytes for an N G obj header that follows whitespace and prints number, generation and offset. It is the same brute-force scan readers fall back on when they repair a broken xref, with the same blind spots: objects inside compressed object streams stay invisible, and uncompressed stream data containing a line such as 7 0 obj gives a false hit

uses
  System.SysUtils, System.IOUtils;

function IsPdfWhite(B: Byte): Boolean;
begin
  Result := B in [0, 9, 10, 12, 13, 32];   // ISO 32000-1 Table 1
end;

function IsDigit(B: Byte): Boolean;
begin
  Result := B in [Ord('0')..Ord('9')];
end;

function ReadUInt(const Data: TBytes; var P: Integer; out Value: Int64): Boolean;
begin
  Value := 0;
  Result := (P < Length(Data)) and IsDigit(Data[P]);
  while (P < Length(Data)) and IsDigit(Data[P]) do
  begin
    Value := Value * 10 + (Data[P] - Ord('0'));
    Inc(P);
  end;
end;

function SkipWhite(const Data: TBytes; var P: Integer): Boolean;
var
  Start: Integer;
begin
  Start := P;
  while (P < Length(Data)) and IsPdfWhite(Data[P]) do
    Inc(P);
  Result := P > Start;
end;

function MatchAt(const Data: TBytes; P: Integer; const S: RawByteString): Boolean;
var
  K: Integer;
begin
  Result := P + Length(S) <= Length(Data);
  for K := 1 to Length(S) do
    if Result and (Data[P + K - 1] <> Ord(S[K])) then
      Result := False;
end;

// Try to read "N G obj" at P; on success P points just past "obj"
function ReadObjHeader(const Data: TBytes; var P: Integer;
  out Num, Gen: Int64): Boolean;
begin
  Result := ReadUInt(Data, P, Num) and SkipWhite(Data, P) and
            ReadUInt(Data, P, Gen) and SkipWhite(Data, P) and
            MatchAt(Data, P, 'obj');
  if Result then
    Inc(P, 3);
end;

procedure ListObjectHeaders(const FileName: string);
var
  Data: TBytes;
  I, P: Integer;
  Num, Gen: Int64;
begin
  Data := TFile.ReadAllBytes(FileName);
  I := 0;
  while I < Length(Data) do
  begin
    P := I;
    if IsDigit(Data[I]) and ((I = 0) or IsPdfWhite(Data[I - 1])) and
       ReadObjHeader(Data, P, Num, Gen) then
    begin
      Writeln(Format('%5d %d obj  at byte %d', [Num, Gen, I]));
      I := P;
    end
    else
      Inc(I);
  end;
end;

The second routine does what a reader does on open. It searches backward for startxref, jumps to the offset, reads each subsection, and checks that every in-use entry lands on the matching header; it splits rows by whitespace, so the 19-byte variant passes too. Run against the listing, it reports objects 1 to 5 at 19, 68, 125, 263 and 333, all OK. Against the Windows-1252 copy it reports startxref 427 does not point at an xref table, while the scanner finds the objects four bytes earlier. Against the compressed HotPDF file it says startxref points at object 9, a cross-reference stream: run qpdf first

procedure CheckXrefTable(const FileName: string);
var
  Data: TBytes;
  P, K, Tail: Integer;
  XrefPos, First, Count, Offset, Gen, N, Num, G: Int64;
  Kind: AnsiChar;
begin
  Data := TFile.ReadAllBytes(FileName);
  // startxref lives in the last few hundred bytes; search backward
  Tail := Length(Data) - 9;
  while (Tail >= 0) and not MatchAt(Data, Tail, 'startxref') do
    Dec(Tail);
  if Tail < 0 then
    raise Exception.Create('no startxref: truncated file?');
  P := Tail + 9;
  SkipWhite(Data, P);
  ReadUInt(Data, P, XrefPos);
  P := Integer(XrefPos);
  if not MatchAt(Data, P, 'xref') then
  begin
    if ReadObjHeader(Data, P, N, G) then
      Writeln(Format('startxref %d points at object %d: a cross-reference stream',
        [XrefPos, N]))
    else
      Writeln(Format('startxref %d does not point at an xref table', [XrefPos]));
    Exit;
  end;
  Inc(P, 4);
  SkipWhite(Data, P);
  // one or more subsections: "first count" followed by count entries
  while ReadUInt(Data, P, First) do
  begin
    SkipWhite(Data, P);
    ReadUInt(Data, P, Count);
    SkipWhite(Data, P);
    for K := 0 to Count - 1 do
    begin
      ReadUInt(Data, P, Offset);  SkipWhite(Data, P);
      ReadUInt(Data, P, Gen);     SkipWhite(Data, P);
      Kind := AnsiChar(Data[P]);  Inc(P);
      SkipWhite(Data, P);
      if Kind <> 'n' then
        Continue;
      N := First + K;
      Tail := Integer(Offset);
      if ReadObjHeader(Data, Tail, Num, G) and (Num = N) and (G = Gen) then
        Writeln(Format('object %d: offset %d OK', [N, Offset]))
      else
        Writeln(Format('object %d: offset %d does not start "%d %d obj"',
          [N, Offset, N, Gen]));
    end;
  end;
end;

Writing a readable PDF with HotPDF

To compare your own output with the annotated listing, have the writer keep everything as text: in HotPDF, Compression := cmNone (the default), UseXRefStream := False for a classic table, and UseObjectStreams := False so every object keeps its header. Compiled with Delphi 13 for Win32, the routine below wrote a 1,806-byte file with seven objects; the two routines above confirmed all seven offsets, and the content stream held the same 72 720 Td and (Hello, World!) Tj as the hand-written file. TextOut measures Y down from the top of the page, so 792 − 720 = 72 lands on the same baseline once TextFromBaseLine(True) makes Y the baseline. The extras are an /Info dictionary, an /ID pair in the trailer, and, because SimpleLatinFonts routes plain ASCII in a standard face to an unembedded simple font, a font dictionary with /WinAnsiEncoding, /Widths and a font descriptor

uses
  System.SysUtils, Vcl.Graphics, HPDFDoc;

procedure WriteReadablePdf(const FileName: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;        // do not open a viewer after saving
    PDF.FileName := FileName;
    PDF.Compression := cmNone;      // content streams stay plain text
    PDF.UseXRefStream := False;     // classic xref table, not a /Type /XRef stream
    PDF.UseObjectStreams := False;  // every object keeps its own "N G obj" header
    PDF.SimpleLatinFonts := True;   // ASCII in a standard face: small unembedded font
    PDF.BeginDoc;
    PDF.CurrentPage.Size := Letter;
    PDF.CurrentPage.SetFont('Arial', [], 24);
    PDF.CurrentPage.TextFromBaseLine(True);
    PDF.CurrentPage.TextOut(72, 792 - 720, 0, 'Hello, World!');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

Hand-typed files are for learning. Production output needs offsets, stream lengths and trailers that stay right on every save, and compression, object streams and cross-reference streams switched back on once debugging is done. The HotPDF Delphi Component keeps that bookkeeping for Delphi and C++Builder applications, and three property assignments give you a text-readable PDF whenever you need to open one in an editor and see exactly what was written