celsowm / pagyra-php
Pure PHP behavioral port of pagyra-js HTML-to-PDF engine
Requires
- php: >=8.2
- ext-dom: *
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- dev-fix/text-indent-word-wrap-inline-units
- dev-fix/collapsed-inline-block-line-strut
- dev-fix/inline-block-negative-margins
- dev-feature/list-item-markers
- dev-feature/content-scale-option
- dev-feature/clickable-link-annotations
- dev-fix/margin-padding-shorthand-vs-ua-defaults
- dev-fix/print-media-viewport-test-wrong-indices
- dev-fix/stale-hello-world-golden-snapshot
- dev-fix/linked-stylesheet-test-wrong-child-index
- dev-fix/test-fixtures-missing-border-style
- dev-fix/hidden-border-test-wrong-property-name
- dev-fix/background-shorthand-color
- dev-fix/windows-path-regex-unknown-modifier
- dev-feature/basic-table-layout
- dev-fix/unsupported-display-falls-back-to-block
- dev-feature/float-left-right-siblings
- dev-feature/winansi-graceful-fallback
- dev-fix/composer-test-script-excludes-missing-parity-suite
- dev-feature/text-decoration-underline
- dev-refactor/decouple-header-footer
This package is auto-updated.
Last update: 2026-11-03 20:39:06 UTC
README
Fresh PHP port of pagyra-js.
The previous PHP implementation was intentionally removed. From this point forward, pagyra-js is the behavioral reference for this project.
Status
The owned pure-PHP pipeline now reaches real PDF output: DOM/CSS, block and inline layout, basic float and table layout, recursive pagination, physical page fragmentation, display-list paint preparation, Unicode TrueType embedding/subsetting, JPEG/PNG image XObjects, solid/dashed/dotted/rounded borders, text-decoration underline/line-through, clickable link annotations, CSS alpha and PDF serialization are implemented for the current supported subset.
Current pipeline:
HTML -> Pagyra DOM -> merged CSS/resources -> cascade -> computed style tree -> font resolution -> page style resolution -> block/inline layout -> line boxes -> pagination -> physical page fragments -> display list -> PDF serialization
Current foundation:
- Composer + PHPUnit structure;
Pagyra::prepareHtmlRender()public API;Pagyra::renderHtmlToPdf()returns PDF bytes produced by the owned PHP pipeline for the currently supported paint subset;- validated
RenderHtmlOptionsdefaults, including explicitresourceBaseDirfor deterministic local resource resolution; - Pagyra-owned DOM model backed by
DOMDocumentonly at the parsing boundary; - fragment/document normalization following the
pagyra-jsmodel; - attributes, IDs, classes, inline styles, text content, image and SVG recognition;
- embedded
<style>collection plus local<link rel="stylesheet">loading; - linked stylesheet
url(...)rewriting relative to the stylesheet file itself; - CSS declaration and stylesheet parsing foundation;
- declaration splitting that preserves semicolons inside quoted/function values such as data URLs;
border,border-top/right/bottom/left,border-width,border-styleandborder-colorshorthand expansion with declaration-order and!importantprecedence;backgroundshorthand expansion for color/image/repeat/position/size, with URL backgrounds plus linear/radial gradients reaching the paint pipeline; attachment/origin/clip parsing remains a narrower subset;margin/paddingshorthand expansion into their four-top/-right/-bottom/-leftlonghands at cascade time (1/2/3/4-value box syntax), so shorthand values correctly override the UA-stylesheet longhand defaults onp/h1-h3/ul/olinstead of being silently ignored;- border width keywords aligned with the reference (
thin=1px,medium=3px,thick=5px), including the default medium width when a border style is supplied without an explicit width; border-style:none|hiddenproduces zero border geometry in both block and atomic-inline layout instead of merely suppressing paint;- tag/class/ID compound selectors;
- descendant and child combinators;
- attribute selectors (
[attr],=,~=,|=,^=,$=,*=); - specificity and source-order resolution;
!important, including interaction with inline styles;- inherited properties, including
visibility, where inheritance only fills a property the UA stylesheet did not set on the element, so a UA element rule (a { color: #0000EE },strong { font-weight: bold }) still wins over the inherited value; - CSS custom properties and
var()fallback resolution; - print
@mediaevaluation for the currently supported media-query subset; - initial UA/default styles for core block/inline elements, headings, paragraphs and lists;
- block-level UA display for the elements the reference's table omits but its global default covers (
<blockquote>,<figure>,<figcaption>,<pre>,<address>,<dl>/<dt>/<dd>,<fieldset>,<form>,<aside>,<hgroup>,<center>,<details>,<summary>,<dir>,<menu>,<legend>), plus the CSS 2.1 default-stylesheet spacing where its absence is visibly wrong (blockquote/figure indentation,<pre>'s preserved newlines and monospace family,<dd>indentation, italic<address>, centered<center>); - UA defaults ported from the reference's element table for
<hr>(block box plus the 1px#a0a0a0rule and 0.5em vertical margins),<u>/<s>/<del>/<strike>decorations,<h4>/<h5>/<h6>sizes/weight/margins,<code>'s monospace family,<a>'s#0000EElink color and<td>/<th>padding, with<th>bold and centered; list-itemmarkers for<ol>/<ul>(list-style-position: outside):disc/circle/squarebullets anddecimal,decimal-leading-zero,lower/upper-alpha(-latin) andlower/upper-romannumbering, resolved and formatted as in the reference'slist-utils, painted in the list's left padding before the item's first line; the ordered counter is a sibling count that additionally honours<ol start>and<li value>, andlist-style-type:none/list-style:noneon the item or the list suppresses the marker;- styled DOM tree exposed by
prepareHtmlRender(); LayoutNode/LayoutBoxtree exposed byprepareHtmlRender();- normal-flow block width/height resolution;
- content/padding/border/margin box geometry;
- CSS box sizing (
content-box/border-box); min-width,max-width,min-height,max-height;- horizontal
automargins for fixed-width blocks; - adjacent sibling vertical-margin collapsing;
- block
display:nonefiltering; - block-level replaced elements (
<img style="display:block">and inline SVG) are sized by the replaced-element rules and painted, instead of collapsing into a full-width zero-height box that nothing draws; auto margins center them like any fixed-width block, and one reached through an inline formatting context (inside aninline-block) takes a line of its own without being moved bytext-align; - inline content is laid out in flow order between block siblings, each run of consecutive inline children forming its own anonymous segment, instead of all of it starting at the block's content top and painting over the block children;
float: left/float: rightsiblings use BFC-scoped exclusion regions that shorten following line boxes; inline/replaced/block-content floats, side-specificclear, recursive shrink-to-fit and retry on the next float row when the current slot is full are supported;- a table cell's declared
heightis a lower bound, not a fixed size, as CSS defines for table cells, so a cell whose content outgrows it still reports a box that covers that content instead of leaving it unclaimed by pagination; - basic
<table>grid layout for uniform<tr><td>rows, reading<thead>/<tbody>/<tfoot>row-group wrappers transparently; column widths are distributed from each column's shrink-to-fit measurement, scaled down proportionally when they overflow the table's content width; display:flexlays items out in rows or columns with wrapping,order,gap,flex-grow/-shrink/-basis(and theflexshorthand),justify-content,align-items/align-self(includingstretch),align-contentand main-axis auto margins;display:gridsizes px/%/fr/auto/minmax()/repeat()column tracks (withauto-fill), places items by line numbers and spans or auto-placement, sizes rows to content orgrid-template-rows/grid-auto-rows, and honorsgapandalign/justify-items/-self;grid-template-areas/ named areas are implemented, while named grid lines are not;visibility:hidden|collapsesuppresses display-list paint while preserving normal layout geometry; descendants can override inherited visibility withvisibility:visible;- centralized
TextMetricsabstraction; - real Base14 advance widths drive text measurement whenever the family maps to a standard font, so a run is measured with the same glyph widths the viewer advances by; the ported coefficient heuristic stays as the fallback for unmapped families;
- Base14 width tables are keyed by the WinAnsi byte the serializer writes (the reference's own tables are StandardEncoding above
0x7F, which mismeasures accented text) and include the italic/oblique faces this port's serializer selects; - heuristic text-metrics fallback ported from
pagyra-jscoefficients/calibration; - styled inline fragment collection preserving
font-family,font-size,font-weight,font-style, color and other computed properties; - per-fragment token measurement, so style/font changes can affect wrapping;
white-space: normal,nowrap,pre,pre-wrapandpre-linehandling for the current inline formatter;- explicit newline preservation for preformatted modes;
<br>forces a line break regardless of the activewhite-spacemode (and is skipped when hidden withdisplay:none), including consecutive<br>s producing an empty line at the block's line height;- oversized-word splitting for
overflow-wrap: anywhere,overflow-wrap: break-wordandword-break: break-all, with the legacyword-wrapname accepted as an alias foroverflow-wrap; text-align: left,center,right,endand first-passjustifyspacing;text-indentfirst-line indent (positive or negative), inherited by descendant blocks, narrowing only the first line for wrapping, alignment and justification;- inline-formatter length values (
text-indent, atomic-box margin/padding) acceptin/cm/mm/pcin addition topx/pt/em/rem/%; vertical-align: baseline,middle,top,bottom,text-top,text-bottom,super,subplus px/pt/em/rem/% shifts;- two-pass inline vertical placement so raised/lowered runs can expand the effective line box;
- fallback baseline follows the
pagyra-jsascent/half-leading model (0.75 * font-sizeascent when font ascent metrics are unavailable); - atomic inline-box participation for
inline-block,inline-flex,inline-grid,inline-table, images and inline SVG; - atomic inline wrapping uses full outer size: content + padding + border + margins;
AtomicInlineBoxexposes content size plus margin/padding/border edge metrics, nested inlinecontentLines, and real nested block-layout subtrees throughcontentBlocks;- recursive atomic-inline paint for backgrounds, solid/rounded borders, nested
contentLinesand nested block-formatting-context subtrees; LineBoxexposes a unified ordered inline item view so surroundingTextRunandAtomicInlineBoxpaint in inline/layout order instead of all text first and atomics afterward;- intrinsic PNG/JPEG/WebP metadata extraction from data URLs and readable local resources;
- SVG intrinsic sizing from
width/height/viewBoxfor inline SVG and SVG image sources; - relative image/SVG sources resolved against explicit
resourceBaseDir; - image
width/heightattributes remain usable as sizing fallback when source metadata is unavailable; - image sizing preserves intrinsic aspect ratio when only CSS width or height is specified;
- image
min/max-widthandmin/max-heightconstraints preserve aspect ratio when the opposite dimension remains automatic; - oversized intrinsic images shrink to available content width before min/max constraints, matching the
pagyra-jssizing order; - replaced image sizing accounts for margin, padding, border and
box-sizing; object-fit: fill|contain|cover|none|scale-downpaint geometry;- first-pass
object-positionparsing for keywords and percentages; - image clipping against the replaced-element content box when object-fit overflows;
- object-fit clipping uses the content-box border radius derived exactly through border-radius shrink by border edges and then padding edges, matching the reference path;
- PDF image clipping uses a rounded Bézier path when the content-box radius is non-zero, otherwise the existing rectangular clip path;
inline-blockwithwidth:autouses internal max-content width as its initial intrinsic width;- explicit inline-block width runs the child inline formatter so internal wrapping determines automatic height;
- nested inline-block line boxes are translated into their real content-box coordinates;
- deterministic
LineBoxoutput with x/y/width/height/baseline/text plus styledTextRunandAtomicInlineBoxchildren; - mixed-font-size baseline alignment inside a line;
- basic inherited
text-transformapplication without requiringext-mbstring; - text-driven intrinsic block height for headings and paragraphs;
- big-endian sfnt binary reader;
- TTF/OTF metric parsing for
head,hhea,maxp,hmtxand Unicodecmapformats 4/12; - classic
kernformat-0 pair parsing; - font registry with family/weight/style selection;
- font variant fallback aligned with
pagyra-js: requested style is preferred first, then the nearest available numeric weight; - glyph-advance and kerning-based text measurement with heuristic fallback;
fontConfig.fontFaceDefssupport for local,file://, relative-to-resourceBaseDirand base64 data-URL sources;- CSS
@font-faceextraction from embedded and linked stylesheets; - CSS font source selection supports sfnt-compatible TrueType/OpenType and WOFF1 sources; WOFF1 is reconstructed to sfnt before metrics/subsetting/embedding, while WOFF2 remains pending;
- base64 embedded
@font-facesources can be parsed directly from CSS and participate in text measurement; - TrueType PDF embedding through Type0 + Identity-H + CIDFontType2 +
FontFile2; ToUnicodeCMaps for Unicode extraction, including non-BMP UTF-16 surrogate pairs;- sparse Identity TrueType subsetting with
.notdefretention, composite-glyph dependency closure and rebuilt sfnt checksums; - embedded text uses glyph widths and classic kern adjustments in
TJarrays; letter-spacingandword-spacingare reflected in PDF text output for the current supported length subset;- default
@page { ... }resolution for named/custom page sizes, portrait/landscape orientation, margin shorthand/longhands and!importantprecedence; @page :first,@page :leftand@page :rightmargin profiles with page-specific cascade/specificity and:firstparticipation in right-page rules;- variable page-flow mapping whose continuous content starts are accumulated from each physical page's actual usable height;
- page style / print viewport stabilization uses the most constrained page variant and re-evaluates matching
@mediarules; - proportional clamping when page margins exceed the resolved page dimensions;
- resolved default page size is exposed in
PreparedRender.pageSize;PreparedRender.marginsremains the default margin set andPreparedRender.pageMarginsexposesdefault/first/left/rightprofiles; - first-pass pagination for
break-before,break-after, legacypage-break-*,left/rightpage parity,break-inside: avoid,widowsandorphans; - forced breaks,
break-inside, widows and orphans propagate recursively through descendant block flow while the continuous layout tree remains unchanged; - widow/orphan positive-integer parsing aligned with the reference, so signed forms such as
+2are rejected and use fallback behavior; - physical page model with preserved skipped parity pages, including parity skips under variable page heights; a page a placement only spans because a descendant was pushed across it carries no entry, so a page skipped by
break-before: left|rightcomes out genuinely blank instead of taking the spanning element's background and borders; - top-level
PagePlacement, per-pagePageFragment, typed per-lineLineFragmentand recursive descendantBlockFragmentgeometry; - descendant block and text-line fragmentation while preserving the continuous layout tree unchanged;
- a block is fragmented on every page its whole subtree reaches, not only the pages its own box covers, so descendants pushed past their wrapper by a widow/orphan or break-inside shift still reach the page instead of being dropped;
PreparedRender.displayListwith physical per-page box/text/image/border paint commands and page-pseudo-specific margin offsets;background-colorfills, including rounded backgrounds for normalized ellipticalborder-radiusvalues;- CSS
border-radiusshorthand (1-4values and/elliptical syntax), per-corner longhands, percentages and global CSS radius normalization; - fragmented top-level rounded boxes keep only the applicable top or bottom corner radii on their first/last physical page fragments;
- per-side solid border fills with independent widths/colors and
currentColorfallback; - uniform solid rounded borders are painted as vector outer-minus-inner rounded rings using PDF even-odd fill;
- mixed/non-solid border sets follow the reference side-stroke geometry: centered side paths,
dashedas3w on / 3w off,dottedasw on / w off, butt caps, and fragment-aware top/bottom suppression; - text paint commands preserve family, weight, style, font size and color;
- CSS color alpha for background, solid/patterned borders and text through deduplicated PDF
ExtGStateresources; - clickable PDF link annotations (
/Subtype /Linkwith a URI action) for<a href>text runs, one annotation rect per text run;<a>carries the reference's default link color but no default underline, and the clickable rect follows only the text run's own box, not a reflowed multi-line link area; - JPEG XObjects embedded directly with
/DCTDecodeand resource deduplication; - PNG grayscale/RGB XObjects using original
IDAT+/FlateDecode+ PNG predictor when possible; - PNG RGBA and grayscale+alpha split into color plus
/SMaskwithout GD/Imagick; - indexed/palette PNG (
PLTE) support for bit depths 1/2/4/8; - indexed PNG
tRNStransparency converted to a grayscale soft mask, including packed palette indices; - grayscale/RGB PNG
tRNSrepresented efficiently as PDF color-key/Maskarrays; - pure-PHP PDF 1.4 object/xref/trailer serialization;
- Base14 font selection for Times/Helvetica/Courier normal/bold/italic variants when no embeddable TrueType face is available;
- WinAnsi text serialization for ASCII, Latin-1 and common CP1252 punctuation in the Base14 fallback, sharing one codepage table with the metrics so measured and drawn text cannot drift apart;
- unsupported characters outside the Base14 WinAnsi repertoire fall back to
?instead of throwing or being silently corrupted; - geometry primitives (
Rect,Edges,Box); - 96-DPI CSS unit conversions matching
pagyra-js; - CSS length parsing and resolution for absolute, viewport, relative, percentage,
calc()and container-query units; - RGBA/hex/rgb/named-color parsing foundation;
- unit/integration/parity test suites kept separate;
- GitHub Actions CI configuration for PHP 8.2, 8.3 and 8.4.
Example local font configuration:
$prepared = Pagyra::prepareHtmlRender([ 'html' => '<p style="font-family: MyFont">Hello</p>', 'resourceBaseDir' => '/path/to/document', 'fontConfig' => [ 'fontFaceDefs' => [[ 'family' => 'MyFont', 'weight' => 400, 'style' => 'normal', 'src' => 'fonts/my-font.ttf', ]], ], ]);
CSS @font-face can also load local or embedded sfnt font data:
<style> @font-face { font-family: "MyFont"; src: url("fonts/my-font.ttf") format("truetype"); } p { font-family: "MyFont"; } </style> <p>Hello</p>
Page descriptors and page pseudo-class margins are reflected by prepareHtmlRender():
<style> @page { size: A4 landscape; margin: 12mm 18mm; } @page :first { margin-top: 24mm; } @page :left { margin-left: 24mm; } @page :right { margin-right: 24mm; } </style> <p>Hello</p>
A real PDF can be produced directly:
$pdf = Pagyra::renderHtmlToPdf([ 'html' => '<h1>Hello</h1><p>PDF generated in pure PHP.</p>', ]); file_put_contents('output.pdf', $pdf);
The optional contentScale option (default 1.0, renderHtmlToPdf only) draws the whole document at a fraction of its natural size while keeping the physical page dimensions unchanged: the layout runs on a page inflated by 1 / contentScale, so roughly 1 / contentScale more content flows onto each sheet, and the serializer scales every page back down. contentScale => 0.8 reproduces the "everything at 0.8x" zoom wkhtmltopdf applies by default, which is useful for comparing the two engines at the same scale.
The optional media option ('print' by default, also 'screen' or 'all') selects which @media blocks are live during the cascade, @page resolution and @font-face collection. A PDF is print, so print is the default; pass media => 'screen' to match wkhtmltopdf invoked without --print-media-type, e.g. for documents that ship @media print { * { color: black !important } } ("black prints faster") and would otherwise lose their link colors.
The optional pagedBodyMargin option ('auto' by default, or 'zero'), as in pagyra-js, decides what happens to the body's margin. The cascade starts at <html> and <body>, so body { ... }, html { font-size } (which is what rem resolves against) and <body class> apply, and the body is laid out as a block box: with auto its margin is whatever the cascade gives it, the UA's 8px unless the document says otherwise; zero removes it unconditionally, so content starts at the page margin.
The optional margins option sets the default page margins in px, 48 on every side unless given. Pass an array keyed by top, right, bottom and left to set some or all sides, or a bare number for the same margin on all four (margins => 37.795 is 10mm, the wkhtmltopdf default); @page rules in the document still apply on top of it. The bare number is a PHP-side extension, since pagyra-js types the option as an object of sides. Any other shape, an unknown key or a negative or non-finite value throws InvalidArgumentException instead of falling back to the default.
The PDF serializer is still a subset renderer, not yet a full pagyra-js replacement. TrueType sfnt fonts (including WOFF1 reconstructed to sfnt) can be embedded and subsetted today, and GPOS kern PairPos format 1/2 contributes kerning. OpenType/CFF (OTTO) embedding through FontFile3, WOFF2 decoding, broader shaping, variable-font support and richer per-codepoint fallback-chain behavior remain pending.
Styled inline content is preserved through layout. For example:
<p>Todo <strong>poder</strong> emana do <em>povo</em>.</p>
produces line boxes containing separate TextRun objects for the normal, bold and italic portions, each measured with its own computed style/font selection.
Atomic inline content can participate in the same line, expose an internal layout and now paint that internal layout recursively:
<p> antes <span style="display:inline-block;width:60px;padding:4px;background:#eee;border:1px solid #333"> hello hello hello </span> <img width="200" height="100" style="width:80px;vertical-align:middle"> depois </p>
The span carries an internal formatting context: all-inline content uses nested contentLines, while block-containing inline-blocks carry real nested contentBlocks; both paint recursively in document order. The image resolves to 80 x 40 content pixels because only its width is overridden. Text and atomic boxes are consumed in one ordered inline sequence for paint.
The inline formatter now delegates block-containing atomic boxes to a real nested block formatting context; inline padding/border also reserves real horizontal line geometry, and ellipsis can retain fitting atomic boxes. Richer Unicode line-breaking rules, hyphenation and browser-specific vertical-align/justification edge cases remain for later slices.
text-decoration paint now covers underline, line-through and overline plus solid/double/dashed/dotted/wavy styles and text-decoration-color, using the reference renderer's geometry where applicable.
Still pending in block layout: broader reuse of intrinsic sizing in flex/grid and the remaining edge cases of full CSS BFC semantics. Relative/absolute/fixed positioning exists; floats use scoped exclusions, side-specific clearance and multi-row placement. Table layout honors colspan, rowspan, captions, vertical-align, border-collapse: collapse, recursive min/max-content sizing and <col>/<colgroup> width hints (including spans). thead/tfoot now repeat on each physical table fragment and consume real page space; tables whose body contains multi-row rowspan currently use the legacy non-repeating pagination path rather than duplicating a spanning cell incorrectly. Grid named areas are implemented; named grid lines are not.
Still pending in pagination/paint: isolated opacity grouping for normal non-atomic inline spans (layout boxes and atomic inline boxes already use PDF Transparency Group Form XObjects; stacking order flattens positioned descendants across the physical page and honors negative / normal-auto / non-negative numeric z-index), complete rounded asymmetric per-side borders, border styles beyond the current solid/dashed/dotted subset (double, groove, ridge, inset, outset), WebP paint and Adam7 PNG. Basic inline-SVG vector shapes now paint directly to PDF with viewBox/aspect-ratio mapping, transforms, fill/stroke and path curves; SVG defs/gradients/use/text/image/clipPath/mask/filter remain pending. 2D CSS transforms (matrix, translate, scale, rotate, skew and transform-origin) are implemented as paint scopes for blocks and atomic inline boxes; 3D/perspective and transformed link-hit geometry remain open. Element opacity uses isolated PDF transparency groups for layout/atomic boxes, with the existing per-run fallback retained only for normal inline spans; broader clipping semantics remain open.
Selectors cover type/class/id/attribute selectors, the four combinators, the structural pseudo-classes, :not()/:is()/:where()/:root, and ::before/::after generated content. ::first-line and ::first-letter are implemented with documented partial semantics; ::marker, the remaining shorthands/property parsers, complete Chromium-derived UA styles, richer media queries, richer @font-face descriptors and the remaining pagyra-js CSS surface are still pending.
Remote HTTP resource loading is intentionally not enabled in the current PHP resource layer; local deterministic resources are resolved through explicit paths / resourceBaseDir.
See PLAN.md for the porting roadmap and parity criteria, and docs/SUPPORT_MATRIX.md for the detailed live support/limitation matrix.
Goal
Provide a pure-PHP HTML-to-PDF engine whose observable rendering behavior tracks pagyra-js as closely as practical, while exposing an idiomatic PHP API.
Requirements
- PHP >= 8.2
- ext-dom
- Composer
Development rule
A feature is not considered ported because equivalent-looking PHP code exists. It is considered ported only when fixtures shared with pagyra-js produce equivalent layout/rendering results within explicitly defined tolerances.