Pop PHP
The Toolkit

PDFs

pop-pdf builds PDF documents out of objects — a document holding pages, pages holding text, images and paths — and compiles them to a real PDF file. It reads in the other direction too: importing an existing document, merging several, rendering HTML, and pulling the text back out. All of it is native PHP, with no external binary and no third-party PDF library behind it.

BASH
composer require popphp/pop-pdf

Creating a Document#

Four objects and one call. A Document holds pages and fonts, a Page holds content, and Pop\Pdf\Pdf compiles the result:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Pdf;

$document = new Document();
$document->addFont(Font::ARIAL);

$page = $document->createPage(Page::LETTER);
$page->addText(new Text('Hello World', 12), Font::ARIAL, 50, 742);

Pdf::writeToFile($document, __DIR__ . '/../storage/my-document.pdf');

That writes a 912-byte PDF 1.7 file whose text reads back as Hello World. createPage() is the document acting as a factory — it builds the page, adds it to the document, and hands it back — so you rarely construct a Page yourself unless you want to add it later with addPage().

Pdf::outputToHttp($document, $filename, $forceDownload, $headers) is the other output. It sends the PDF to the client with a Content-Disposition of inline, or attachment when $forceDownload is true.

Two settings on the document are worth knowing before you place anything. setCompression(true) compresses the content streams. And the origin: PDF's native origin is the bottom left of the page, so a y of 742 on a 792-point letter page is 50 points down from the top. That's where the 742 above comes from. setOrigin() translates a more comfortable origin into the native one for you:

PHP
use Pop\Pdf\Document;

$document = new Document();

$document->setCompression(true);
$document->setOrigin(Document::ORIGIN_TOP_LEFT);

The choices are ORIGIN_TOP_LEFT, ORIGIN_TOP_RIGHT, ORIGIN_BOTTOM_LEFT, ORIGIN_BOTTOM_RIGHT and ORIGIN_CENTER. Set it once, before placing content, and every coordinate afterward is measured from there.

Beyond createPage() and addPage(), a document manages its pages with addPages(), copyPage(), orderPages(), deletePage() and setCurrentPage(). getPages() returns them all and getPage() takes a 1-based page number.

Pages, Text and Fonts#

A page is a size. Page::LETTER is 612 by 792 points, and the class carries constants for the A, B, envelope and North American series — A4, A5, LEGAL, TABLOID, ENVELOPE_10 and the rest. Two numbers make a custom size:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Page;

$document = new Document();

$letter = $document->createPage(Page::LETTER);   // 612 x 792
$custom = $document->createPage(500, 1000);

Text and Fonts#

Text needs a font, and the font has to be on the document before any page uses it. Twenty-six constants on Pop\Pdf\Document\Font name the standard PDF fonts — Font::ARIAL, Font::HELVETICA_BOLD, Font::TIMES_ROMAN, Font::COURIER, Font::SYMBOL, Font::ZAPF_DINGBATS and their italic and bold variants — and those need no file, since every PDF reader has them.

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Text;

$document = new Document();
$document->addFont(Font::HELVETICA_BOLD);

$text = new Text('Hello World', 12);

$page = $document->createPage(Page::LETTER);
$page->addText($text, Font::HELVETICA_BOLD, 50, 742);

addText() takes the text object, the font, and the x and y of its baseline. A Text carries its own appearance: setSize(), setFillColor(), setStrokeColor(), setStroke(), setRotation(), setLeading() and setCharWrap().

Wrapping and Alignment#

For anything longer than a line, an Alignment is better than a character wrap, because it wraps on words inside a bounding area rather than counting characters:

PHP
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Document\Page\Text\Alignment;

$text = new Text($longString, 12);

$text->setAlignment(Alignment::createLeft(50, 350, 16));

createLeft(), createRight() and createCenter() all take the left and right bounds of the box and the leading between lines. A style is the other way to keep text consistent — createStyle() names a font and size on the document, and addText() accepts that name in place of a font:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Text;

$document = new Document();
$document->addFont(Font::ARIAL);
$document->createStyle('body', Font::ARIAL, 12);

$page = $document->createPage(Page::LETTER);
$page->addText(new Text('Styled text', 12), 'body', 50, 742);

Embedding a Font#

Any TrueType, OpenType or Type1 file can be embedded instead. Pass the path to the Font constructor, hand it to embedFont(), and reference it by getName():

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Text;

$font = new Font('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf');

$document = new Document();
$document->embedFont($font);

$page = $document->createPage(Page::LETTER);
$page->addText(new Text('123 ПРИВІТ', 24), $font->getName(), 50, 700);

Embedded TrueType and OpenType fonts are compiled as composite CID fonts with a /ToUnicode map, so non-Latin scripts render correctly and the text still extracts. The whole font is embedded with no glyph subsetting, which is why the DejaVu Sans document above comes out at 863 KB against 912 bytes for the standard-font one.

Embed a font covering the characters you write — writeToFile() names the font and the character it cannot render.

Text::setCharWrap() splits on byte counts, so use an Alignment for wrapping with an embedded CID font.

Font::getStringWidth() measures a string, which is what you need to center something or decide where the next element goes. It works for standard and embedded fonts alike:

PHP
use Pop\Pdf\Document\Font;

$font = new Font(Font::HELVETICA_BOLD);

$font->getStringWidth('Hello World', 12);  // 66.672

Images and Drawing#

An image is a Pop\Pdf\Document\Page\Image, built from a file or from raw bytes, and placed with addImage():

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Image;

$image = Image::createImageFromFile(__DIR__ . '/../storage/photo.jpg');
$image->resizeToWidth(200);

$document = new Document();
$page     = $document->createPage(Page::LETTER);

$page->addImage($image, 50, 500);

Image::loadImageFromStream() takes the bytes instead of a path. Four methods resize — resizeToWidth(), resizeToHeight(), resize() and scale() — each taking an optional $preserveResolution flag. Left false, the image resource itself is scaled down, which shrinks the PDF; set true and the full-resolution data is kept and only the placement size changes, which looks crisper in print.

The resize methods record a target rather than mutating the image, so getWidth() keeps reporting the source dimensions until the document is compiled.

An image's origin is its bottom edge, same as everything else on the page. That's the placement mistake to expect: a 320-pixel-tall image at y 742 on a letter page puts its bottom 50 points from the top, so all but the last 50 points bleed off. Subtract the height — y 422 rather than 742 — to put the image's top 50 points down from the page top.

Vector drawing is a Path. Its style constant decides whether the shape is stroked, filled, or both, and colors come from pop-color exactly as they do for images:

PHP
use Pop\Color\Color;
use Pop\Pdf\Document;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Path;

$path = new Path(Path::FILL_STROKE);

$path->setFillColor(Color::rgb(155, 20, 20))
     ->setStrokeColor(Color::rgb(81, 125, 153))
     ->setStroke(5)
     ->drawRectangle(50, 400, 320, 240);

$document = new Document();
$page     = $document->createPage(Page::LETTER);

$page->addPath($path);

Eleven draw methods cover the shapes: drawLine(), drawRectangle(), drawRoundedRectangle(), drawSquare(), drawRoundedSquare(), drawPolygon(), drawEllipse(), drawCircle(), drawArc(), drawChord() and drawPie(). Where a shape takes a width and a height, omitting the height makes it equal to the width.

The style is a constructor argument or setStyle(), and the useful ones are Path::STROKE, Path::FILL, Path::FILL_STROKE and their _CLOSE and _EVEN_ODD variants. The CLIPPING family uses the path as a clipping region for whatever is drawn after it rather than painting it.

Annotations#

An annotation is an invisible rectangle over part of a page that turns whatever is under it into a link. Nothing about the annotation draws — you place the visible text first, then put the annotation at the same coordinates.

Pop\Pdf\Document\Page\Annotation\Url links out to a URL. Its constructor takes the width and height of the hot area and the address, and addUrl() places it:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Annotation\Url;
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Pdf;

$document = new Document();
$document->addFont(Font::ARIAL);

$page = $document->createPage(Page::LETTER);

$page->addText(new Text('Visit Google', 12), Font::ARIAL, 50, 742);
$page->addUrl(new Url(100, 15, 'https://www.google.com/'), 50, 742);

Pdf::writeToFile($document, __DIR__ . '/../storage/linked.pdf');

The 100, 15 is a box 100 points wide and 15 tall — sized to cover the text, which is why Font::getStringWidth() is worth reaching for when the label is not a fixed string. The 50, 742 passed to addUrl() is the same origin the text was placed at, so the box sits over it.

Annotation\Link is the internal counterpart, jumping to a destination inside the same document. Its constructor takes the box width and height followed by the x and y to land on, and two setters name the page and the zoom:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Annotation\Link;
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Pdf;

$document = new Document();
$document->addFont(Font::ARIAL);

$page1 = $document->createPage(Page::LETTER);
$page1->addText(new Text('Jump to page 2', 12), Font::ARIAL, 50, 742);

$page2 = $document->createPage(Page::LETTER);
$page2->addText(new Text('This is the destination', 12), Font::ARIAL, 50, 742);

$link = new Link(120, 15, 10, 752);
$link->setPageTarget(2)
     ->setZTarget(110);

$page1->addLink($link, 50, 742);

Pdf::writeToFile($document, __DIR__ . '/../storage/internal.pdf');

setPageTarget() takes a 1-based page number and setZTarget() a zoom percentage, so the example above lands on page 2 at 110%. The 10, 752 in the constructor is where on that page the view is positioned.

Both annotation types compile into a page's /Annots array, and the URL's address goes into the file as a /URI action. Neither is a pop-pdf abstraction over something else — they are the PDF specification's own link annotations, so every reader honors them, unlike the form fields in the next section.

Annotations are placed per page, and a page can carry any number of them. There's no method to remove one after it is added, so build the page's links as you build its content rather than adding them all at the end.

Form Fields#

A fillable PDF is a Pop\Pdf\Document\Form on the document, plus field objects on a page naming that form. Three field classes cover everything supported: Pop\Pdf\Document\Page\Field\Text for single- and multi-line text, Field\Choice for drop-downs and multi-selects, and Field\Button for checkboxes and radios.

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Form;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Pdf;

$document = new Document();
$document->addForm(new Form('contact_form'));
$document->addFont(new Font(Font::ARIAL));

$firstName = new Page\Field\Text('first_name');
$firstName->setWidth(200)->setHeight(20);

$comments = new Page\Field\Text('comments');
$comments->setWidth(500)->setHeight(150)->setMultiline();

$city = new Page\Field\Choice('city');
$city->addOption('New Orleans')
     ->addOption('New York')
     ->setCombo()
     ->setWidth(200)
     ->setHeight(20)
     ->setFont(Font::ARIAL)
     ->setSize(11);

$agree = new Page\Field\Button('agree');
$agree->addOption('Yes')->setWidth(20)->setHeight(20);

$page = $document->createPage(Page::LETTER);

$page->addField($firstName, 'contact_form', 50, 650)
     ->addField($city, 'contact_form', 300, 550)
     ->addField($agree, 'contact_form', 50, 325)
     ->addField($comments, 'contact_form', 50, 100);

Pdf::writeToFile($document, __DIR__ . '/../storage/contact.pdf');

addField() takes the field, the name of the form it belongs to, and the x and y of its bottom left corner. The width and height are on the field rather than the placement, so a field is sized once and positioned wherever.

The variants are flags on the field. setMultiline() turns a text field into a textarea. setCombo() makes a choice field a drop-down and setMultiSelect() makes it a list box; a choice field also takes setFont() and setSize() for the text inside it. setRadio() turns a button into a radio rather than a checkbox — though a group of radio buttons sharing one value is not supported, so each button is independent.

Fields draw no border or label of their own. The rectangles and captions in a form you have seen are Path and Text objects placed at the same coordinates, which means a form page is built twice: once for what it looks like, once for what it captures.

Importing an Existing PDF#

Pdf::importFromFile() parses a PDF back into a Document of the same page, font, image and text objects you would have built. From there it is an ordinary document — add to it, and write it out:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Pdf;

$document = Pdf::importFromFile(__DIR__ . '/../storage/incoming.pdf');

$document->addFont(Font::ARIAL);
$document->getPage(1)->addText(new Text('Received 2026-08-24', 10), Font::ARIAL, 50, 700);

Pdf::writeToFile($document, __DIR__ . '/../storage/stamped.pdf');

A second argument selects pages, as an integer or an array of 1-based page numbers, and the imported document holds only those:

PHP
use Pop\Pdf\Pdf;

$document = Pdf::importFromFile(__DIR__ . '/../storage/incoming.pdf', [2, 4, 6]);

Importing pages [2] from a three-page document gives a one-page document, and that page is then page 1getPage() numbers what you imported, not what the source called it.

Pdf::importRawData($data, $pages) does the same from bytes rather than a path, for a PDF that arrived in a request body or came out of a database column.

Pdf::importFromImages() builds a document from images instead, one page per image, sized to fit it:

PHP
use Pop\Pdf\Pdf;

$document = Pdf::importFromImages([
    __DIR__ . '/../storage/scan-1.jpg',
    __DIR__ . '/../storage/scan-2.png',
], 70);

The second argument is the JPEG quality for the embedded images, defaulting to 70. A single path may be passed instead of an array.

Merging Documents#

Pdf::merge() takes an array of file paths and returns one Document with every source's pages appended in order:

PHP
use Pop\Pdf\Pdf;

$document = Pdf::merge([
    __DIR__ . '/../storage/cover.pdf',
    __DIR__ . '/../storage/body.pdf',
    __DIR__ . '/../storage/appendix.pdf',
]);

Pdf::writeToFile($document, __DIR__ . '/../storage/complete.pdf');

Merging three documents of three, two and one pages gives a six-page document, in that order, with every page's text intact. Pdf::mergeRawData() is the same operation over an array of raw PDF strings.

The result is a Document, not a file, which is the useful part: you can add a cover page, stamp a page number on each sheet, or drop a page before writing anything to disk.

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Pdf;

$document = Pdf::merge([
    __DIR__ . '/../storage/cover.pdf',
    __DIR__ . '/../storage/body.pdf',
]);

$document->addFont(Font::ARIAL);

foreach ($document->getPages() as $number => $page) {
    $page->addText(new Text('Page ' . ($number + 1), 9), Font::ARIAL, 300, 30);
}

Pdf::writeToFile($document, __DIR__ . '/../storage/numbered.pdf');

getPages() is indexed from zero, which is why the counter above adds one — unlike getPage(), which takes a 1-based number.

Merging is a full parse and recompile of each source, not a byte-level splice, so what comes out is a document pop-pdf wrote.

Merging is a full parse and recompile, so add form fields to the merged document rather than to its sources.

Pdf::merge() takes two or more sources.

Rendering HTML to PDF#

HTML rendering is native. There's no headless browser, no wkhtmltopdf, no external binary of any kind — pop-pdf parses the markup and CSS itself and lays it onto pages. Everything in this section was run on a machine with no PDF tooling installed beyond the extension-free PHP the rest of this page uses.

Pdf::importFromHtml() is the one-liner:

PHP
use Pop\Pdf\Pdf;

$document = Pdf::importFromHtml('<h1>Hello World!</h1><p>Simple paragraph.</p>');

Pdf::writeToFile($document, __DIR__ . '/../storage/from-html.pdf');

Pdf::importFromHtmlFile() reads the markup from a file, following its linked CSS and images. Both take an existing Document as a second argument when you want the HTML appended to something you have already built.

For control over the fonts, page size and CSS, use Pop\Pdf\Build\Html\Parser directly. It takes the document, parses markup and stylesheets separately, and process() does the layout:

PHP
use Pop\Pdf\Build\Html\Parser;
use Pop\Pdf\Document;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Pdf;

$document = new Document();
$document->addFont(Font::ARIAL);
$document->createPage(Page::LETTER);

$parser = new Parser($document);
$parser->parseHtml($html, __DIR__);
$parser->parseCss($css);
$parser->process();

Pdf::writeToFile($parser->document(), __DIR__ . '/../storage/invoice.pdf');

The second argument to parseHtml() is a base directory, so relative image paths in the markup resolve against it. document() returns the laid-out document afterward.

Tables work, including colspan, rowspan, and a <thead> that repeats when the table crosses a page break. Column widths come from the content, with an explicit CSS or HTML width honored first and the remaining space distributed proportionally. border-width, border-color and background-color apply to any element, not only cells.

An invoice with a three-column table, a colspan row and a rowspan row compiles cleanly and renders through Ghostscript with no errors, and its text extracts back in reading order with the cells tab-separated.

The limits are the ones a fixed page size imposes. There's no border-collapse, so adjacent cell borders are drawn twice; nested tables are not supported; and a single row taller than a page is rendered best-effort on that page rather than split. Uncommon CSS properties and deeply nested block markup are where support thins out.

Extracting Text#

Extraction is native too, with no external binary and no third-party PDF library — including for documents using embedded fonts, not only the standard fourteen:

PHP
use Pop\Pdf\Pdf;

$text = Pdf::extractTextFromFile(__DIR__ . '/../storage/incoming.pdf');

Pdf::extractTextFromData() takes bytes instead of a path. Both accept two further arguments: a page selection, as an integer or an array of 1-based page numbers, and a page limit that caps how many pages are walked at all — which is the one to reach for when the input is untrusted and could be enormous:

PHP
use Pop\Pdf\Pdf;

$firstPages = Pdf::extractTextFromFile($file, [1, 2, 3]);
$bounded    = Pdf::extractTextFromFile($file, null, 50);

Pages come back separated by a blank line, and a page with nothing on it contributes nothing rather than an empty entry. A three-page document whose pages read "One page 1", "One page 2" and "One page 3" extracts as those three strings in order.

A scanned document has no text to extract, and a fruitless extraction looks the same as an empty one. Three methods answer the question directly:

PHP
use Pop\Pdf\Pdf;

if (Pdf::isImageOnlyDocument(__DIR__ . '/../storage/incoming.pdf')) {
    // every page is a single full-page image — route it to OCR
}

$pages = Pdf::getImageOnlyPages(__DIR__ . '/../storage/incoming.pdf');
// [0 => true, 1 => false, 2 => true]

isImageOnlyDocument() is the whole-document answer and getImageOnlyPages() the per-page breakdown, keyed from zero. isImageOnlyData() and getImageOnlyPagesFromData() are the raw-data forms, and all four take the same page-selection and page-limit arguments as the extractors.

Building a two-page document with Pdf::importFromImages() and asking these three about it gives exactly what you would want: isImageOnlyDocument() returns true, getImageOnlyPages() returns [0 => true, 1 => true], and extractTextFromFile() returns an empty string. A text document answers false on all pages.

Extraction handles what the compiler emits, embedded CID fonts included — text written through an embedded DejaVu Sans and containing Cyrillic extracts back byte-identical. That's the same round trip pdftotext performs on the output, which is worth knowing: pop-pdf's documents read correctly in other tools, not only its own.

Form field values, annotation targets and the rest of the PDF specification's surface go past what this page covers — see the pop-pdf README.

See Also#

  • Images — preparing the images before they go onto a page
  • CSS & Color — the Pop\Color values every fill and stroke takes, and the CMYK string PDF wants
  • File Storage — where generated documents are written and read back
  • Requests & Responses — the response Pdf::outputToHttp() writes into
  • pop-pdf README — every page, font, field and parser option