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.
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:
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:
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
Parseraround theDocumentpassed 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 anyparseCss*()calls are done
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:
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()andparseUri()are static factories that build their ownParserandDocument— reach for the instance methodparseHtml()instead, to parse into aDocumenta parser was already pointed at withsetDocument()
That instance method:
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-colorproves 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:
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:
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,
#idand.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:
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.
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:
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:
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:
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:
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:
h1throughh6andpeach carry their ownfont-size(32/28/24/20/16/14pt and 12pt respectively) on top of the 10-point fallback aboveh1–h6andstrongcarry a bold weight that does switch the rendered font (confirmed above)emcarries an italicfont-styleentry that font resolution doesn't act on — see the<em>row in the markup table aboveacarries 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 —
pandh1–h6each carry their own pre-registeredfont-size, so a default there is overruled the way a tag selector is by a more specific one - every other tag, including
div,spanandli, takes the default directly coloraccepts a hex string or anrgb(...)value the same wayparseCss()does; anything outside those formats raises aPop\Pdf\Build\Html\Exceptionrather 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():
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:
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:
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:
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:
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:
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:
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> colspanandrowspanclaim the extra grid slots a cell covers, so a cell after acolspan="2"lands in the correct next column rather than overlapping it
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
widthattribute or CSSwidthon 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:
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'sid, falling back to itsname, then toform1,form2and so on in document order - a control takes its own
nameattribute, falling back tofield1,field2and so on — the<button>above, having noname, compiles asfield4 - two
<form>elements resolving to the same name share oneDocument\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:
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>'ssizeand a<textarea>'scolscount characters, at 7 points each —size="10"is 70 points wide - a
<textarea>'srowscounts lines, at 16 points each —rows="4"is 64 points tall - a
widthorheightattribute, and CSSwidth/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:
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:
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__:
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
Documentobject every conversion here returns