Pop PDF
Build

HTML to PDF

Html\Parser reads HTML and CSS and writes directly into a Document — headings, paragraphs, lists, links, images, tables and form controls become pages, text streams, images, paths and interactive fields, with no headless browser or external rendering engine anywhere in the path.

Converting a String of HTML#

Pdf::importFromHtml() is the fastest way in: hand it a string, get a ready-to-write Document back.

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

$html = <<<HTML
<h1>Quarterly Report</h1>
<p>Revenue is <strong>up 12%</strong> from last quarter, driven largely by
<a href="https://example.com/report">the new product line</a>.</p>
<ul>
    <li>North region: on target</li>
    <li>South region: ahead of plan</li>
</ul>
HTML;

$document = Pdf::importFromHtml($html, document: new Document());
Pdf::writeToFile($document, filename: __DIR__ . '/report.pdf');

Reading that PDF back with Pdf::extractTextFromFile() returns every word: "Quarterly Report Revenue is up 12% from last quarter, driven largely by the new product line. North region: on target South region: ahead of plan" — one page, nothing dropped.

The table below is what actually came back when each of these tags was rendered and checked, not what the tag names suggest:

Markup Behavior
<h1>–<h6> A block heading, bold, at 32/28/24/20/16/14pt
<p>, <div>, <span> A block of text, styled like any other tag by name, id or class
<strong>, <b> Switches to the bold variant of the current font
<em>, <i> Parses without error and carries the surrounding weight — reach for <strong> or a color to set emphasis apart
<a href="..."> Inline text, blue by default from the built-in a selector
<br> A line break inside the current text
<ul>, <ol>, <li> Each <li> is its own line of text; no bullet or number is drawn
<img src="..."> Placed at the current position, sized from width/height attributes or CSS
<table> and its row/cell tags A full grid layout — see Tables

A block reads its own text plus, for every inline tag nested inside it at any depth, that tag's own text — <p>Normal <strong>bold <em>and italic</em> tail</strong> text</p> renders every word: "Normal bold and italic tail text".

Converting a File#

Pdf::importFromHtmlFile() takes a path instead of a string, and resolves every relative <img src> and stylesheet <link href> against the HTML file's own directory rather than the working directory the script runs from:

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

$document = Pdf::importFromHtmlFile(__DIR__ . '/invoice.html', document: new Document());
Pdf::writeToFile($document, filename: __DIR__ . '/invoice.pdf');

With invoice.html sitting next to a logo.png and referencing it as <img src="logo.png" width="150">, the image resolves and lands on the page even though the script that calls importFromHtmlFile() lives somewhere else entirely.

Using the Parser Directly#

Pdf::importFromHtml(), importFromHtmlFile() and importFromHtmlUri() are all a thin wrapper around Html\Parser — construct it directly for anything more than a one-shot conversion:

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

$parser = Parser::parseString('<h1>Direct Parser</h1><p>Body text.</p>', document: new Document());
$parser->process();

$document = $parser->getDocument();

parseString(), parseFile() and parseUri() are all static:

  • each builds a Parser around the Document passed in (or a fresh one, since both arguments default), loads the markup, and hands the parser back — nothing is rendered onto the document yet
  • process() is the step that walks the loaded HTML and adds pages, text and images to the document — call it once parsing and any parseCss*() calls are done
PHP
use Pop\Pdf\Document;
use Pop\Pdf\Build\Html\Parser;

$parser = Parser::parseFile(__DIR__ . '/invoice.html', document: new Document());
$parser->process();

parseUri() works the same way against a URL instead of a local path — fetching over HTTP was confirmed against a local php -S server rather than a public host:

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

$parser = Parser::parseUri('https://example.com/report.html', document: new Document());
$parser->process();

Pdf::importFromHtmlUri() is the one-shot equivalent, handing back the Document rather than the parser.

setDocument() and getDocument() are the plain accessors for the Document a parser targets:

  • useful when a parser needs to be pointed at a document built up elsewhere before process() runs
  • parseString(), parseFile() and parseUri() are static factories that build their own Parser and Document — reach for the instance method parseHtml() instead, to parse into a Document a parser was already pointed at with setDocument()

That instance method:

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

$document = new Document();
$parser   = new Parser();
$parser->setDocument($document);

$parser->parseHtml('<p>Targets the document passed to setDocument().</p>')->process();

Pdf::writeToFile($document, filename: __DIR__ . '/targeted.pdf');

Extracting targeted.pdf back out returns "Targets the document passed to setDocument()." — proof the paragraph landed in the Document instance setDocument() pointed at, not in some other one.

Styling#

Inline and Embedded CSS#

parseCss() applies a CSS string directly:

  • it stacks with every other parseCss*() call on the same parser
  • styling a class with background-color proves the stylesheet reached rendering rather than merely registering — with no CSS applied at all, the same markup draws zero paths on the page; with the call below, it draws one, a filled box behind the paragraph's text

Confirmed by rendering:

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

$parser = Parser::parseString('<p class="callout">Read this first.</p>', document: new Document());
$parser->parseCss('.callout { background-color: #ffe0b2; padding-top: 4px; padding-bottom: 4px; }');
$parser->process();

var_dump(count($parser->getDocument()->getPage(1)->getPaths())); // int(1)

getCss() reads the accumulated stylesheet back as a Pop\Css\Css object — a quick way to confirm a selector registered before chasing why it isn't showing up in the rendered page:

PHP
use Pop\Css\Css;

$css = $parser->getCss();
var_dump($css instanceof Css);              // true
var_dump($css->hasSelector('.callout'));    // true

Style selectors reach the same selector table from three sources:

  • tag name, #id and .class
  • a style="..." attribute written directly on an element
  • a <style> block written into the markup

All apply, and the inline style attribute wins over every other selector matching the same element — the normal CSS cascade:

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

$parser = Parser::parseString(
    '<p style="color:#ff0000; font-size:30px">Styled inline.</p>',
    document: new Document()
);
$parser->process();

That paragraph's text stream carries 30 Tf and 1 0 0 rg — the inline attribute's size and color, not the 12-point black the parser's built-in p selector would otherwise draw.

A <style> block written into the markup is read the same way, without a separate call. The sample below wraps its <head>/<body> in <html> because it must: a fragment with a <head> but no <html> wrapper around it parses to a page with nothing on it — a bare fragment with no <head> at all renders fine without one.

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

$html = '<html><head><style>p { font-size: 28px; color: #0000ff; }</style></head>'
    . '<body><p>Block styled.</p></body></html>';

$parser = Parser::parseString($html, document: new Document());
$parser->process();

The paragraph draws at 28 Tf in 0 0 1 rg — the <style> block's rule, not the built-in default.

A <link rel="stylesheet" type="text/css" href="..."> tag inside <head> loads automatically when the markup comes from parseFile(), parseHtmlFile() or importFromHtmlFile() — the href resolves against the HTML file's directory, the same way an <img src> does:

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

// invoice.html:  <head><link rel="stylesheet" type="text/css" href="invoice.css"></head>
$document = Pdf::importFromHtmlFile(__DIR__ . '/invoice.html', document: new Document());

A tag selector's font-size applies too, alongside .class and #id selectors — p { font-size: 24px; } passed to parseCss() renders every <p> at 24pt, confirmed by the 24 Tf the paragraph's text stream carries afterward.

CSS From a File#

parseCssFile() reads a .css file from disk on its own, independent of whatever HTML is loaded:

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

$parser = Parser::parseString('<p>Styled from a file.</p>', document: new Document());
$parser->parseCssFile(__DIR__ . '/report.css');
$parser->process();

CSS From a URI#

parseCssUri() is the same call against a URL — confirmed here too against a local server rather than a live host:

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

$parser = Parser::parseString('<p>Styled from a URI.</p>', document: new Document());
$parser->parseCssUri('https://example.com/report.css');
$parser->process();

Default Styles#

Every parser starts with the same fallback, before any CSS is applied: Arial, 10-point, normal weight, black, with a 14-point line height. getDefaultStyles() returns that array, and setDefaultStyle() changes one entry in it:

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

$parser = new Parser(new Document());
$parser->setDefaultStyle('font-family', 'TimesNewRoman');

var_dump($parser->getDefaultStyle('font-family')); // "TimesNewRoman"

A parser also ships six heading selectors, plus p, a, strong and em, pre-registered before any markup is parsed:

  • h1 through h6 and p each carry their own font-size (32/28/24/20/16/14pt and 12pt respectively) on top of the 10-point fallback above
  • h1–h6 and strong carry a bold weight that does switch the rendered font (confirmed above)
  • em carries an italic font-style entry that font resolution doesn't act on — see the <em> row in the markup table above
  • a carries its blue
  • all of this is independent of any CSS supplied

setDefaultStyle() reaches font-size, line-height and color the same way a matching selector's value would, each converting to a real number or color and applying directly:

  • the one exception is ordinary CSS specificity — p and h1–h6 each carry their own pre-registered font-size, so a default there is overruled the way a tag selector is by a more specific one
  • every other tag, including div, span and li, takes the default directly
  • color accepts a hex string or an rgb(...) value the same way parseCss() does; anything outside those formats raises a Pop\Pdf\Build\Html\Exception rather than rendering silently

Page Setup#

Page Size#

Pages the parser creates default to a 612-by-792-point US Letter page. Set a different one up front with an additional $pageSize argument for parseString(), parseFile(), parseUri(), Pdf::importFromHtml(), importFromHtmlFile() and importFromHtmlUri():

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

$parser = new Parser(new Document(), pageSize: Page::A4);
$parser->parseHtml('<p>A4 page.</p>')->process();

$page = $parser->getPage();
var_dump($page->getWidth(), $page->getHeight()); // int(595), int(842)

A [width, height] array in points takes the place of the constant for a size that has none — new Parser(new Document(), pageSize: [400, 600]). getPageSize() reports whichever is in effect, LETTER until something changes it.

setPageSize() sets it after construction instead, and passing a $height alongside $size gives an explicit width and height in points:

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

$parser = new Parser(new Document());
$parser->setPageSize(400, height: 900);
$parser->parseHtml('<p>Custom page.</p>')->process();

$page = $parser->getPage();
var_dump($page->getWidth(), $page->getHeight()); // int(400), int(900)

Every page the parser creates from that point on — including the extra pages it adds when content overflows — uses whichever size was set last.

Margins#

The four default margins, measured directly, are 80 points on top, 60 on the right, 60 on the bottom and 60 on the left. setPageMargins() sets all four at once, and getPageMargins() returns them as an associative array:

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

$parser = new Parser(new Document());
$parser->setPageMargins(100, 50, 40, 30);

var_dump($parser->getPageMargins()); // ['top' => 100, 'right' => 50, 'bottom' => 40, 'left' => 30]

setPageTopMargin(), setPageRightMargin(), setPageBottomMargin() and setPageLeftMargin() set one side without touching the other three, and each has a matching getter. getCurrentY() is where a changed margin actually shows up — it resolves the first page and returns the y-coordinate content starts at, so a taller top margin moves that starting point down:

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

$parser = new Parser(new Document());
$parser->setPageTopMargin(120)->setPageRightMargin(45)->setPageBottomMargin(45)->setPageLeftMargin(45);

var_dump($parser->getPageTopMargin());  // int(120)
var_dump($parser->getCurrentY());       // int(672), i.e. 792 - 120

Controlling Flow#

The parser tracks an x, y cursor as it walks the HTML — y counts down from the top margin, and every block advances it by that block's rendered height. setX() and setY() override that cursor directly, repositioning where the next parsed node starts:

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

$parser = new Parser(new Document());
$parser->parseHtml('<p>First block.</p>')->process();

$parser->setY(400);
$parser->setX(150);
$parser->parseHtml('<p>Second block, repositioned.</p>')->process();

The first paragraph starts at the left margin, 712 points up from the page's bottom edge on a default Letter page. The second, after setY(400) and setX(150), starts at x 150 and y 312 — exactly 792 - 80 - 400 — regardless of where the first paragraph left the cursor.

getCurrentY() is what the parser calls internally before placing anything: it resolves the current page — creating the first one, at whatever size and margins are set, if none exists yet — and returns the y-coordinate the next node would render at. setYOverride() pins the exact y-coordinate the next node renders at instead, bypassing the accumulated cursor; a <table> uses it internally to hand flow back at the exact height the table finished at:

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

$parser = new Parser(new Document());
$parser->parseHtml('<p>Above the override.</p>')->process();

$parser->setYOverride(250);
$parser->parseHtml('<p>Resumes exactly at the override.</p>')->process();

The second paragraph starts at y 250 exactly, one call after setYOverride(250) — the same mechanism is available directly for placing a node at a precise height after adding something to the page by hand.

newPage() adds a fresh page at the parser's current size and margins, resets the cursor to the top margin, and returns the y-coordinate that fresh page starts at — the parser calls it automatically whenever content would run past the bottom margin, and it is available directly for a deliberate page break:

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

$parser = new Parser(new Document());
$parser->parseHtml('<p>Page one.</p>')->process();
$parser->newPage();
$parser->parseHtml('<p>Page two.</p>')->process();

var_dump($parser->getDocument()->getNumberOfPages()); // 2

Tables#

Table\Grid walks a <table> into an explicit row-and-column grid before anything is drawn:

  • <thead>, <tbody> and <tfoot> rows are read in document order regardless of which section they sit in
  • a row counts as a header row when it comes from <thead>, or when every one of its cells is a <th>
  • colspan and rowspan claim the extra grid slots a cell covers, so a cell after a colspan="2" lands in the correct next column rather than overlapping it
PHP
use Pop\Pdf\Document;
use Pop\Pdf\Pdf;

$html = <<<HTML
<table>
<tr><th>Item</th><th>Quantity</th><th>Price</th></tr>
<tr><td colspan="2">Widgets (assorted)</td><td>\$19.99</td></tr>
<tr><td rowspan="2">Bulk Order</td><td>100</td><td>\$4.50</td></tr>
<tr><td>250</td><td>\$4.00</td></tr>
</table>
HTML;

$document = Pdf::importFromHtml($html, document: new Document());

Extracting the result confirms the grid: "Item Quantity Price Widgets (assorted) $19.99 Bulk Order 100 $4.50 250 $4.00" — the colspan cell prints once, spanning the width of the two columns it covers, and the rowspan cell prints once rather than repeating on the row below it.

Table\Layout works in passes:

  • column width: it first measures every cell's natural width from its text and font, taking the widest cell in each column, then distributes the table's available width across columns proportionally to those natural widths
  • a width attribute or CSS width on a single-column cell overrides that column with an explicit pixel or percentage value instead — <td width="70%"> on a two-column table gives that column 70% of the table's width, leaving the rest for the other column
  • every row is at least 20 points tall regardless of content; a row's actual height is whichever of its cells needs the most lines to wrap

A table too long for one page breaks across as many pages as it needs, and every header row — <thead> or an all-<th> row — repeats at the top of each new page the table continues onto; an 80-row table under one header row confirmed the header present on all three pages it spread across.

drawBox() paints each cell's background-color and border-width before its text, the same box-drawing a styled <td>/<th> uses outside a table:

  • style the <th> or <td> cells themselves — a selector matching the enclosing <tr> draws nothing
  • a plain element selector (th { ... }) or a bare class selector (.hdr { ... }) both reach the cells and draw the box
  • a compound selector joining the two (th.hdr { ... }) matches nothing — write them as two separate rules instead

Form Markup#

<form> markup becomes a real, fillable PDF form. Form\Layout converts each control in the subtree into a Document\Page\Field\* object and the enclosing <form> into a Document\Form, one control per line, the same way Table\Layout lays out rows:

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

$html = <<<HTML
<form id="signup">
<p>Full name</p>
<input type="text" name="full_name" value="Alex Doe" />
<p>Country</p>
<select name="country">
<option value="ca">Canada</option>
<option value="us" selected>United States</option>
</select>
<p>Agree to the terms</p>
<input type="checkbox" name="agree" value="yes" checked />
<button>Send</button>
</form>
HTML;

$document = Pdf::importFromHtml($html, document: new Document());

Pdf::writeToFile($document, filename: __DIR__ . '/signup.pdf');

$document->getForm('signup')->getNumberOfFields(); // 4

That conversion happens on its own — there is no flag to set and no parser method to call. The four controls compile to the same widget dictionaries Forms & Fields describes, with the text field carrying /V (Alex Doe), the checkbox carrying /AS /yes from its checked attribute, and the <select> carrying /Opt [ [(ca) (Canada)] [(us) (United States)] ] — each option's value paired with the text it displays.

Names come from the markup:

  • the form takes the <form> element's id, falling back to its name, then to form1, form2 and so on in document order
  • a control takes its own name attribute, falling back to field1, field2 and so on — the <button> above, having no name, compiles as field4
  • two <form> elements resolving to the same name share one Document\Form, their controls landing in the same field set

Give a control its label in an element of its own — a <label>, a <p> or a <div> — and it renders on the line above the control. Text placed beside a control inside the same element is the control's sibling rather than its own block, and the widget is what that line renders.

What Each Control Becomes#

Markup Field Notes
<input type="text">, and any unrecognized type Field\Text value attribute becomes /V
<input type="password"> Field\Text setPassword() — /Ff 8192
<input type="file"> Field\Text setFileSelect() — /Ff 1048576
<input type="hidden"> Field\Text zero height, keeps its value, takes no space in the flow
<textarea> Field\Text setMultiline() — /Ff 4096; its text content becomes /V
<select> Field\Choice setCombo(), or setMultiSelect() with a multiple attribute
<input type="checkbox"> Field\Button independent of every other checkbox
<input type="radio"> Field\Button same-named radios in one form become one group
<button>, <input type="submit">, <input type="reset"> Field\Button setPushButton() — /Ff 65536

An <option> gives its value as the export value and its text as the display label. Leave the value off and the text serves as both. A selected option becomes the field's /V, and a checked checkbox or radio becomes the one that starts out on.

A push button renders its caption — the <button> element's own text, or an <input>'s value attribute, defaulting to Submit and Reset for those two types. The caption is drawn into the widget's appearance stream from the document's Arial resource, and the button holds no action of its own.

Sizing a Control#

A control's size comes from its own HTML attribute first, then CSS, then a default per control type:

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

$html = <<<HTML
<style>.wide { width: 50%; }</style>
<form id="signup">
<input type="text" name="a" />
<input type="text" name="b" size="10" />
<input type="text" name="c" class="wide" />
<textarea name="d" rows="4" cols="30"></textarea>
</form>
HTML;

$document = Pdf::importFromHtml($html, document: new Document());

On a letter page those four compile to /Rect [60 692 260 712], [60 668 130 688], [60 644 366 664] and [60 576 270 640]:

  • an <input>'s size and a <textarea>'s cols count characters, at 7 points each — size="10" is 70 points wide
  • a <textarea>'s rows counts lines, at 16 points each — rows="4" is 64 points tall
  • a width or height attribute, and CSS width/height, both take a percentage as a share of the page — width: 50% on a 612-point page is 306 points, the same way a percentage on an <img> resolves
  • the defaults are 200 by 20 for a text input, 300 by 60 for a <textarea>, 150 by 20 for a <select>, 80 by 24 for a button and 14 by 14 for a checkbox or radio

Styling a Control#

border-width, border-color and background-color carry onto the field the same way they carry onto a <div> or a table cell:

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

$html = <<<HTML
<style>input { border-width: 1px; border-color: #999999; background-color: #eeeeee; }</style>
<form id="signup"><input type="text" name="full_name" /></form>
HTML;

$document = Pdf::importFromHtml($html, document: new Document());

That field compiles with /MK << /BC [0.6 0.6 0.6] /BG [0.933 0.933 0.933] >> and /BS << /W 1 >>, the appearance-characteristics and border-style entries a reader draws the box from. A plain element selector (input { ... }) and a bare class selector (.wide { ... }) both reach the control; write the two as separate rules rather than joining them into input.wide, the same rule table cells follow.

Radio Groups#

Radios sharing a name inside one <form> compile to a single grouped field — one shared parent plus one child widget per option — so choosing one clears the others:

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

$html = <<<HTML
<form id="survey">
<p>Preferred contact</p>
<label>Email</label><input type="radio" name="contact" value="email" checked />
<label>Phone</label><input type="radio" name="contact" value="phone" />
<label>Mail</label><input type="radio" name="contact" value="mail" />
</form>
HTML;

$document = Pdf::importFromHtml($html, document: new Document());

$document->getForm('survey')->getNumberOfFields(); // 1

Three options, one field: the parent carries /FT /Btn /T(contact) /Ff 32768 /V /email, and each child carries a /Parent back-reference and its own on and off appearance streams. The group also travels as a unit — a group that would otherwise straddle a page break moves whole to the next page, as long as it fits on one, so its options never compile as two separately-named halves.

Controls Outside a Form#

A control with no <form> ancestor still compiles, into an implicit form named __default__:

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

$document = Pdf::importFromHtml('<p>Name</p><input type="text" name="who" />', document: new Document());

Pdf::writeToFile($document, filename: __DIR__ . '/who.pdf');

$document->getForm('__default__')->getNumberOfFields(); // 1

Every loose control on the document shares that one form, so a page of stray inputs still submits as one set. Field indices fill in when the document compiles, so read getNumberOfFields() after the write.

See Also#

  • Text Layout — the alignment and wrapping rules the same text streams follow outside of HTML
  • Fonts — the standard and embedded fonts an HTML document's font-family resolves against
  • Forms & Fields — the field objects form markup compiles into, and the same fields built by hand
  • Creating Documents — the Document object every conversion here returns