Skip to content

Transform the coordinate space: rotate, scale, skew and mirror

Transform the drawing coordinate space around a pivot you choose. This recipe covers rotation, scaling, skew, and mirroring. Each transform stays isolated in a saved graphics-state block, so it does not affect later content. It follows examples/21-transforms.php.

Terminal window
composer require nextpdf/core:^3

You do not need a Pro or Enterprise package. The transform application programming interface (API) ships with Core and runs on PHP 8.1 through 8.4.

Portable Document Format (PDF) content is drawn in user space. By default, user space has its origin at the lower-left of the page, and one unit equals 1/72 inch (ISO 32000-2 §8.3.2). A transform multiplies the current transformation matrix (CTM) by a new matrix through the cm operator (§8.3.4). Transforms compose by matrix concatenation, so order matters.

NextPDF lets you work in a top-left author coordinate system. Internally, it converts that system to native lower-left user space through the toY() projection in the transform methods. Positions use user-space units: PDF points, where 1 pt equals 1/72 in. To keep a transform local, wrap it between startTransform() and stopTransform(). These methods emit the q (save) and Q (restore) graphics-state operators (§8.4.2). Everything drawn between them inherits the transform. Everything after stopTransform() returns to the prior CTM. Each rotate()/scale()/skewX()/mirrorH() call takes an explicit pivot, so the transform anchors where you expect instead of at the page origin.

The API surface is generated from PHPDoc. The main entry points come from the \NextPDF\Core\Concerns\HasTransforms trait:

  • Document::startTransform(): static — emits q and opens a state block
  • Document::stopTransform(): static — emits Q and closes the block
  • Document::rotate(float $angle, float $x = 0, float $y = 0): static
  • Document::scale(float $sx, float $sy, float $x = 0, float $y = 0): static
  • Document::skewX(float $angle, float $x = 0, float $y = 0): static / skewY(...)
  • Document::mirrorH(float $x = 0): static / mirrorV(float $y = 0): static
  • Document::translateCtm(float $dx, float $dy): static
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();
$doc->setTitle('Coordinate Transforms');
$doc->addPage();
$cx = 60.0;
$cy = 60.0;
// Rotate 30° around (cx, cy). The transform is scoped to this block.
$doc->startTransform();
$doc->rotate(30, $cx, $cy);
$doc->setFont('helvetica', '', 14);
$doc->text($cx, $cy, 'Rotated 30 degrees');
$doc->stopTransform();
// Back to the untransformed CTM — this text is upright.
$doc->setFont('helvetica', '', 10);
$doc->text($cx, $cy + 20, 'Not rotated');
$doc->save(__DIR__ . '/transforms.pdf');
echo "Created: transforms.pdf\n";

This self-contained program runs under the cookbook harness. It mirrors the scaling section of examples/21-transforms.php. Each transform stays in a saved graphics-state block with an explicit pivot. Color and line state are reset at the end, so nothing leaks into a later page.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();
$doc->setTitle('Coordinate Transforms');
$doc->addPage();
$doc->setFont('helvetica', 'B', 13);
$doc->cell(0, 8, 'Scaling a reference square at 0.5x, 1.0x, 1.5x, 2.0x', newLine: true);
$doc->ln(6);
$scaleBaseY = $doc->getY();
$scaleFactors = [0.5, 1.0, 1.5, 2.0];
$doc->setDrawColor(30, 58, 138);
$doc->setLineWidth(0.4);
foreach ($scaleFactors as $idx => $factor) {
$cx = 25.0 + $idx * 45;
$cy = $scaleBaseY + 5;
$doc->startTransform();
$doc->scale($factor, $factor, $cx, $cy); // scale about (cx, cy)
$doc->setFillColor(220, 230, 241);
$doc->rect($cx, $cy, 15, 15, 'DF');
$doc->line($cx, $cy, $cx + 15, $cy + 15);
$doc->stopTransform(); // CTM restored here
// Drawn AFTER the block — at the original scale, untransformed.
$doc->setFont('helvetica', '', 8);
$doc->setTextColor(0);
$doc->text($cx, $scaleBaseY + 38, sprintf('%.1fx', $factor));
}
// Explicit state reset so nothing carries into the next section.
$doc->setTextColor(0);
$doc->setFillColor(255);
$doc->setDrawColor(0);
// The harness sets NEXTPDF_COOKBOOK_OUTPUT and runs this script twice under
// the structural profile (the transform stream itself is deterministic).
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT');
$doc->save($out !== false && $out !== '' ? $out : __DIR__ . '/transforms.pdf');
echo "Wrote transforms.pdf\n";

Expected STDOUT:

Wrote transforms.pdf

The full example covers all four transform families: rotation, scaling, skew, and mirror. Run it with php examples/21-transforms.php; it writes examples/output/21-transforms.pdf.

  • Always pair the block. Every startTransform() must have a matching stopTransform(). An unbalanced q/Q count corrupts the graphics state for the rest of the page (ISO 32000-2 §8.4.2). NextPDF tracks the depth, but the recipe-level contract remains one-to-one.
  • Order is not commutative. Transforms compose by matrix concatenation, so rotate() then scale() is not the same as scale() then rotate(). Apply them inside one block in the order you intend.
  • Pivot defaults to the origin. If you omit the pivot, the transform pivots around the page origin, not the shape. That is usually not what you want, so pass the pivot explicitly.
  • Y axis is author-space. The pivot y is the distance from the author top-left, and NextPDF projects it to native user space. Mixing raw PDF coordinates with the author API produces a mirrored result.
  • State leak. Color, font, and line width set inside a transform block persist after stopTransform(), because in this API surface Q restores only the CTM. Reset these values explicitly if a later section must not inherit them, as the production sample does.

A transform emits one cm operator plus the q/Q pair. Each part is only a few bytes and adds no measurable runtime cost, so the recipe stays within the 1500 ms / 96 MB budget. The reproducibility profile is structural. The output contains a trailer /ID array and creation metadata that are not stable across runs, so you must normalize them before comparison. The transform stream itself is deterministic.

  • Data Residency & Personally Identifiable Information (PII) Mitigations. Not applicable. This recipe draws geometric primitives and short labels. It processes no external or personal data.
  • Safe Telemetry & Log Scrubbing. The recipe writes one fixed progress line. It logs no document content.
  • Threat model. Not applicable. There is no input parsing, no cryptography, and no trust boundary. A transform is a pure content-stream emission.
  • Federal Information Processing Standards (FIPS)-mode behavior. Not applicable. There is no cryptographic operation.
StatementSpecClausereference_id
A transform concatenates a matrix onto the CTM with the cm operator.ISO 32000-2§8.3.4
Transforms compose by matrix concatenation, and order is significant.ISO 32000-2§8.3.4
q saves and Q restores the graphics state, which scopes the transform.ISO 32000-2§8.4.2
Default user space origin is lower-left; one unit is 1/72 inch.ISO 32000-2§8.3.2

This recipe follows the cited ISO 32000-2 graphics-state and transformation clauses. It does not assert blanket ISO 32000-2 conformance; the cited clauses are the only ones this recipe exercises.