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.
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:
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:
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:
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.
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:
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:
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():
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:
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():
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:
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:
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:
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.
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:
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:
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
1 — getPage() 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:
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:
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.
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:
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:
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:
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:
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:
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\Colorvalues 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