The complete script creates two documents: a password-protected PDF and an unencrypted PDF 2.0 wrapper embedding it. The wrapper's page explains how to access the content; its collection identifies which file to open. The embedded content is actually encrypted before insertion.

Run the example

From a core 0.7.1 checkout, with PHP 8.3 or later and OpenSSL:

php examples/pdf20-encrypted-payload.php wrapper.pdf

The example supplies Courier through explicit metrics: FirstChar/LastChar, ASCII widths and a FontDescriptor. PDF 2.0 no longer permits omitting these entries for the fourteen standard fonts. This construction does not embed a font program; profiles requiring embedding need an explicitly embedded font.

The demonstration reader password is demo-reader. It appears on the page and in the source so the downloaded PDF can be inspected. It is unsuitable for a confidential document. SystemRandomSource supplies keys, salts and initialization vectors.

Encrypt the actual document before embedding it

ModernStandardSecurityHandler constructs an explicit encryption operation from prepared passwords, PDF 2.0 permissions and the metadata-encryption choice. The example uses R=6 with AES-256-CBC. FileWriter receives that operation and writes the encryption dictionary and the inner PDF's encrypted strings and streams.

$security = (new ModernStandardSecurityHandler(
    new OpenSslAes256Backend(),
    new SystemRandomSource(),
))->create(
    new PreparedPassword('demo-reader'),
    new PreparedPassword('demo-owner'),
    new Pdf20Permissions(-4),
    new EncryptMetadata(true),
);
$encryptedBytes = (new FileWriter(encryption: $security))->serialize($inner);

The demonstration's ASCII values are already prepared. PreparedPassword represents that explicit choice; its constructor does not normalize an arbitrary password. An application's Unicode inputs must follow the selected handler's preparation contract. The inner PDF also receives its explicit trailer file identifier.

EmbeddedFileStream then receives the encrypted PDF's bytes, the application/pdf MIME type and its source date. The wrapper's Flate filter compresses those bytes; it does not replace their encryption. Adding only an EncryptedPayload name to clear bytes would provide no protection.

Describe the payload and connect its references

The file specification contains EF, AFRelationship /EncryptedPayload and the EP dictionary:

$descriptor = new EncryptedPayload(new NameType('Standard'));
$specification = new FileSpecification(
    new LiteralString('payload.pdf'),
    $options,
    new AssociatedFileRelationship(AFRelationship::EncryptedPayload),
    encryptedPayload: $descriptor,
);

EP.Subtype /Standard identifies the handler actually used by the embedded PDF. EP.Version is an optional version name, distinct from a PDF real number; none is added here. EncryptedPayload describes how to access a file. It does not invent an algorithm or encrypt its data.

The same indirect file specification appears in Names.EmbeddedFiles and in the catalog's AF array. Its name-tree key is payload.pdf. The wrapper contains exactly one file in that tree, as required by the unencrypted-wrapper mechanism.

Select the content while keeping instructions readable

Collection(view: CollectionView::Hidden, initialDocument: ...) writes View /H and D (payload.pdf). D repeats the name-tree key, rather than a disk path or an arbitrary reference. The catalog explicitly links the collection, names and AF association.

The wrapper itself is written without an encryption operation. Its page therefore remains readable to a processor unable to open the embedded content. A capable processor may open the selected document automatically; other viewers show the instructions and their attachment panel. The example also explains manual extraction.

The effective graph validates these relationships for complete and incremental writes: EP descriptor, embedded file, unique name entry, D selection, hidden view and absence of wrapper encryption. Removing a required relationship or replacing the collection with an inconsistent selection fails the operation.

Verify both levels independently

The preview below renders the wrapper, not the decrypted content. Download the PDF, extract payload.pdf and open it with demo-reader to view the inner page. Downloading does not execute any attachment.

tools/validate_pdf20_metadata.py uses pypdf to inspect the wrapper, locate the exact same file in the name tree and AF, extract its bytes and check its R=6 dictionary. It decrypts the file with qpdf, then reads the decrypted page text. Native tests also exercise inconsistent wrappers and incremental changes; successfully rendering the wrapper alone would not demonstrate inner encryption.

Preserved sources: ISO 32000-2, physical pages 109–110 for the wrapper and table 28; file specifications, pages 149–152; and associated-files application note, page 10, §4.1.5. The bundle comes from the PDF Association's sponsored access.

Tutorial path · Metadata and document parts

View the PDF result

This file was generated with the code in this documentation. All pages appear below; open the PDF to use its links and bookmarks.

PDF result pdf20-encrypted-payload: page 1 of 1
Page 1 of 1
Use the browser PDF viewer

This viewer depends on your browser. If the document does not appear, use “Open the PDF” or “Download”.

Complete source code

Code from roukmoute/pdf · View the source at the documented commit ↗

Read the commented script: pdf20-encrypted-payload.php
<?php

declare(strict_types=1);

use PDF\Document\Collections\Collection;
use PDF\Document\Collections\CollectionView;
use PDF\Document\Files as Files;
use PDF\Document\ResourceDictionary;
use PDF\Document\Resources\Font;
use PDF\FileStructure;
use PDF\Security as Security;
use PDF\Serialization\FileWriter;
use PDF\Syntax\ContentStreamsResources\ContentStreams;
use PDF\Syntax\ContentStreamsResources\Operators as Op;
use PDF\Syntax\DataStructure\Date;
use PDF\Syntax\DataStructure\Rectangle;
use PDF\Syntax\DocumentStructure\DocumentCatalog;
use PDF\Syntax\DocumentStructure\PageTree\PageObject;
use PDF\Syntax\DocumentStructure\PageTree\PageTreeNode;
use PDF\Syntax\FileStructure\FileBody;
use PDF\Syntax\FileStructure\FileHeader;
use PDF\Syntax\Filters\FlateDecode;
use PDF\Syntax\Object\ArrayType;
use PDF\Syntax\Object\DictionaryObject\Entry;
use PDF\Syntax\Object\DictionaryType;
use PDF\Syntax\Object\IndirectReference;
use PDF\Syntax\Object\NameType;
use PDF\Syntax\Object\NumericType;
use PDF\Syntax\Object\StringObject\HexadecimalString;
use PDF\Syntax\Object\StringObject\LiteralString;

require dirname(__DIR__) . '/vendor/autoload.php';

// The content helper creates ordinary explicit PDF page/font/operator objects.
$createPage = static function (string ...$lines): PageTreeNode {
    // Each explicit page construction supplies complete PDF 2.0 font metrics.
    $font = require __DIR__ . '/fixtures/pdf20-courier-font.php';
    $resources = new ResourceDictionary(new Font(new DictionaryType(new Entry(new NameType('F1'), new IndirectReference($font)))));
    $operators = [];
    foreach (array_values($lines) as $index => $line) {
        $operators[] = new Op\BeginTextObject();
        $operators[] = new Op\SetTextFontAndSize(new NameType('F1'), new NumericType($index === 0 ? 20 : 12));
        $operators[] = new Op\MoveTextPosition(new NumericType(48), new NumericType(780 - 32 * $index));
        $operators[] = new Op\ShowText(new LiteralString($line));
        $operators[] = new Op\EndTextObject();
    }
    return (new PageTreeNode())->addKid(new PageObject(new Rectangle(new NumericType(0), new NumericType(0), new NumericType(595), new NumericType(842)), $resources, new \PDF\Syntax\Object\StreamObject(new ContentStreams(...$operators))));
};
// This is a real encrypted PDF, not an EP label on clear bytes. Demo passwords
// are intentionally public. File keys, salts and IVs come from SystemRandomSource.
$random = new Security\SystemRandomSource();
$security = (new Security\ModernStandardSecurityHandler(new Security\OpenSslAes256Backend(), $random))->create(new Security\PreparedPassword('demo-reader'), new Security\PreparedPassword('demo-owner'), new Security\Pdf20Permissions(-4), new Security\EncryptMetadata(true));
$identifier = new HexadecimalString($random->bytes(16));
$inner = new FileStructure(new FileHeader('2.0'), new FileBody(new DocumentCatalog($createPage('Encrypted payload', 'This text becomes available after password authentication.'))), trailerEntries: new DictionaryType(new Entry(new NameType('ID'), new ArrayType($identifier, $identifier))));
$encryptedBytes = (new FileWriter(encryption: $security))->serialize($inner);
$payload = new Files\EmbeddedFileStream($encryptedBytes, new NameType('application/pdf'), modificationDate: new Date(new DateTimeImmutable('2026-10-14T00:00:00Z')), filters: new FlateDecode());
$specification = new Files\FileSpecification(new LiteralString('payload.pdf'), new Files\FileSpecificationOptions(embedded: new Files\EmbeddedFileBindings(new Files\EmbeddedFileBinding(Files\EmbeddedFileKey::File, $payload))), new Files\AssociatedFileRelationship(Files\AFRelationship::EncryptedPayload), encryptedPayload: new Files\EncryptedPayload(new NameType('Standard')));
$attachments = new Files\EmbeddedFiles(new Files\EmbeddedFileAttachment(new LiteralString('payload.pdf'), $specification));
$collection = new Collection(view: CollectionView::Hidden, initialDocument: new LiteralString('payload.pdf'));
// ISO 32000-2 7.6.7 requires exactly one EmbeddedFiles entry, a catalog AF
// association and a hidden collection selecting that same name. The wrapper
// stays unencrypted so a processor can display these instructions.
$catalog = new DocumentCatalog($createPage('Encrypted PDF payload', 'This unencrypted wrapper contains one encrypted PDF.', 'Extract payload.pdf from the attachments, then open it.', 'Demonstration reader password: demo-reader', 'The payload uses the Standard security handler with AES-256.', 'Some viewers open the selected payload automatically.'), new Entry(new NameType('Names'), new DictionaryType($attachments->nameDictionaryEntry())), new Files\AssociatedFilesEntry(new Files\AssociatedFiles(new IndirectReference($specification))), $collection->catalogEntry());
$output = $argv[1] ?? __DIR__ . '/pdf20-encrypted-payload.pdf';
(new FileStructure(new FileHeader('2.0'), new FileBody($catalog)))->writeTo($output);
echo $output . PHP_EOL;

Guide source at the documented commit ↗