Search by

celsowm / pagyra-php

celsowm

Pure PHP behavioral port of pagyra-js HTML-to-PDF engine

Package info

github.com/celsowm/pagyra-php

pkg:composer/celsowm/pagyra-php

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0


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 RenderHtmlOptions defaults, including explicit resourceBaseDir for deterministic local resource resolution;
  • Pagyra-owned DOM model backed by DOMDocument only at the parsing boundary;
  • fragment/document normalization following the pagyra-js model;
  • 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-style and border-color shorthand expansion with declaration-order and !important precedence;
  • background shorthand 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/padding shorthand expansion into their four -top/-right/-bottom/-left longhands at cascade time (1/2/3/4-value box syntax), so shorthand values correctly override the UA-stylesheet longhand defaults on p/h1-h3/ul/ol instead 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|hidden produces 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 @media evaluation 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 #a0a0a0 rule and 0.5em vertical margins), <u>/<s>/<del>/<strike> decorations, <h4>/<h5>/<h6> sizes/weight/margins, <code>'s monospace family, <a>'s #0000EE link color and <td>/<th> padding, with <th> bold and centered;
  • list-item markers for <ol>/<ul> (list-style-position: outside): disc/circle/square bullets and decimal, decimal-leading-zero, lower/upper-alpha (-latin) and lower/upper-roman numbering, resolved and formatted as in the reference's list-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>, and list-style-type:none / list-style:none on the item or the list suppresses the marker;
  • styled DOM tree exposed by prepareHtmlRender();
  • LayoutNode/LayoutBox tree exposed by prepareHtmlRender();
  • 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 auto margins for fixed-width blocks;
  • adjacent sibling vertical-margin collapsing;
  • block display:none filtering;
  • 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 an inline-block) takes a line of its own without being moved by text-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: right siblings use BFC-scoped exclusion regions that shorten following line boxes; inline/replaced/block-content floats, side-specific clear, recursive shrink-to-fit and retry on the next float row when the current slot is full are supported;
  • a table cell's declared height is 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:flex lays items out in rows or columns with wrapping, order, gap, flex-grow/-shrink/-basis (and the flex shorthand), justify-content, align-items/align-self (including stretch), align-content and main-axis auto margins; display:grid sizes px/%/fr/auto/minmax()/repeat() column tracks (with auto-fill), places items by line numbers and spans or auto-placement, sizes rows to content or grid-template-rows/grid-auto-rows, and honors gap and align/justify-items/-self; grid-template-areas / named areas are implemented, while named grid lines are not;
  • visibility:hidden|collapse suppresses display-list paint while preserving normal layout geometry; descendants can override inherited visibility with visibility:visible;
  • centralized TextMetrics abstraction;
  • 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-js coefficients/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-wrap and pre-line handling for the current inline formatter;
  • explicit newline preservation for preformatted modes;
  • <br> forces a line break regardless of the active white-space mode (and is skipped when hidden with display: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-word and word-break: break-all, with the legacy word-wrap name accepted as an alias for overflow-wrap;
  • text-align: left, center, right, end and first-pass justify spacing;
  • text-indent first-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) accept in/cm/mm/pc in addition to px/pt/em/rem/%;
  • vertical-align: baseline, middle, top, bottom, text-top, text-bottom, super, sub plus 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-js ascent/half-leading model (0.75 * font-size ascent 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;
  • AtomicInlineBox exposes content size plus margin/padding/border edge metrics, nested inline contentLines, and real nested block-layout subtrees through contentBlocks;
  • recursive atomic-inline paint for backgrounds, solid/rounded borders, nested contentLines and nested block-formatting-context subtrees;
  • LineBox exposes a unified ordered inline item view so surrounding TextRun and AtomicInlineBox paint 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/viewBox for inline SVG and SVG image sources;
  • relative image/SVG sources resolved against explicit resourceBaseDir;
  • image width/height attributes 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-width and min/max-height constraints preserve aspect ratio when the opposite dimension remains automatic;
  • oversized intrinsic images shrink to available content width before min/max constraints, matching the pagyra-js sizing order;
  • replaced image sizing accounts for margin, padding, border and box-sizing;
  • object-fit: fill|contain|cover|none|scale-down paint geometry;
  • first-pass object-position parsing 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-block with width:auto uses 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 LineBox output with x/y/width/height/baseline/text plus styled TextRun and AtomicInlineBox children;
  • mixed-font-size baseline alignment inside a line;
  • basic inherited text-transform application without requiring ext-mbstring;
  • text-driven intrinsic block height for headings and paragraphs;
  • big-endian sfnt binary reader;
  • TTF/OTF metric parsing for head, hhea, maxp, hmtx and Unicode cmap formats 4/12;
  • classic kern format-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.fontFaceDefs support for local, file://, relative-to-resourceBaseDir and base64 data-URL sources;
  • CSS @font-face extraction 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-face sources can be parsed directly from CSS and participate in text measurement;
  • TrueType PDF embedding through Type0 + Identity-H + CIDFontType2 + FontFile2;
  • ToUnicode CMaps for Unicode extraction, including non-BMP UTF-16 surrogate pairs;
  • sparse Identity TrueType subsetting with .notdef retention, composite-glyph dependency closure and rebuilt sfnt checksums;
  • embedded text uses glyph widths and classic kern adjustments in TJ arrays;
  • letter-spacing and word-spacing are 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 !important precedence;
  • @page :first, @page :left and @page :right margin profiles with page-specific cascade/specificity and :first participation 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 @media rules;
  • proportional clamping when page margins exceed the resolved page dimensions;
  • resolved default page size is exposed in PreparedRender.pageSize; PreparedRender.margins remains the default margin set and PreparedRender.pageMargins exposes default/first/left/right profiles;
  • first-pass pagination for break-before, break-after, legacy page-break-*, left/right page parity, break-inside: avoid, widows and orphans;
  • 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 +2 are 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|right comes out genuinely blank instead of taking the spanning element's background and borders;
  • top-level PagePlacement, per-page PageFragment, typed per-line LineFragment and recursive descendant BlockFragment geometry;
  • 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.displayList with physical per-page box/text/image/border paint commands and page-pseudo-specific margin offsets;
  • background-color fills, including rounded backgrounds for normalized elliptical border-radius values;
  • CSS border-radius shorthand (1-4 values 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 currentColor fallback;
  • 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, dashed as 3w on / 3w off, dotted as w 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 ExtGState resources;
  • clickable PDF link annotations (/Subtype /Link with 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 /DCTDecode and resource deduplication;
  • PNG grayscale/RGB XObjects using original IDAT + /FlateDecode + PNG predictor when possible;
  • PNG RGBA and grayscale+alpha split into color plus /SMask without GD/Imagick;
  • indexed/palette PNG (PLTE) support for bit depths 1/2/4/8;
  • indexed PNG tRNS transparency converted to a grayscale soft mask, including packed palette indices;
  • grayscale/RGB PNG tRNS represented efficiently as PDF color-key /Mask arrays;
  • 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.