Signature capture and PDF stamping
Validate a canvas signature sent as a data URL, then stamp it onto a chosen page of an existing PDF at a percentage position.
LaravelUtilitiespdfsignaturefpdiupload
Code
<?php
namespace App\Signing;
use InvalidArgumentException;
use setasign\Fpdi\Fpdi;
final class SignatureStamper
{
private const MAX_BYTES = 200_000;
private const PNG_MAGIC = "\x89PNG\r\n\x1a\n";
/** Turn a canvas.toDataURL('image/png') string into verified PNG bytes. */
public static function decode(string $dataUrl): string
{
if (! preg_match('#^data:image/png;base64,([A-Za-z0-9+/=]+)$#', $dataUrl, $m)) {
throw new InvalidArgumentException('Signature must be a PNG data URL.');
}
$bytes = base64_decode($m[1], true);
if ($bytes === false || strlen($bytes) > self::MAX_BYTES || ! str_starts_with($bytes, self::PNG_MAGIC)) {
throw new InvalidArgumentException('Signature image is invalid or too large.');
}
return $bytes;
}
/**
* Copy every page of $pdfPath and draw the signature on $page.
* $xPct / $yPct are the top-left corner as a percentage of the page, so the
* placement survives different paper sizes. Returns the new PDF bytes.
*/
public function stamp(string $pdfPath, string $png, int $page, float $xPct, float $yPct, float $widthMm = 40): string
{
$tmp = tempnam(sys_get_temp_dir(), 'sig').'.png';
file_put_contents($tmp, $png);
try {
$pdf = new Fpdi();
$pages = $pdf->setSourceFile($pdfPath);
if ($page < 1 || $page > $pages) {
throw new InvalidArgumentException("Page {$page} is outside 1-{$pages}.");
}
for ($i = 1; $i <= $pages; $i++) {
$tpl = $pdf->importPage($i);
$size = $pdf->getTemplateSize($tpl);
$pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
$pdf->useTemplate($tpl);
if ($i === $page) {
$pdf->Image($tmp, $xPct / 100 * $size['width'], $yPct / 100 * $size['height'], $widthMm);
}
}
return $pdf->Output('S');
} finally {
@unlink($tmp);
}
}
}
Needs: setasign/fpdi@^2.6, setasign/fpdf@^1.8
How it works
What it solves
Letting a person draw a signature in the browser and have it appear on a real PDF is two separate problems: trusting the image the browser sends, and placing it on the right page without pixel maths. This class handles both in one small place.
How it works
decode() accepts only a data:image/png;base64, string, decodes it in strict mode, caps the size at 200 KB and checks the eight PNG magic bytes, so a renamed script or oversized blob is rejected before it touches the filesystem.
stamp() uses FPDI to import each page of the source document as a template, recreates a page of identical size, and draws the signature only on the requested page. Position comes in as percentages of the page width and height, which means the front end can send the same numbers whatever the paper size or zoom level was when the field was placed.
Adapting it
- Store the returned bytes on a private disk and keep the original untouched, so you always have a pristine copy to compare with.
- Add a second
Image()call or aCell()to print the signer name and timestamp beside the drawing. - Replace the fixed 40 mm width with a value per field if your templates carry different signature box sizes.
Pitfalls
- FPDF can only embed PNGs with transparency when the GD extension is installed.
- FPDI cannot import PDFs using newer compression or encryption; run those through a converter first.
- The percentage origin is the top-left of the page, while many PDF viewers report coordinates from the bottom-left, so convert on the client.
How to use it
- Run
composer require setasign/fpdi setasign/fpdf. - Put
SignatureStamper.phpinapp/Signing/. - In your controller, call
SignatureStamper::decode($request->string('signature'))to get validated PNG bytes. - Stamp it:
(new SignatureStamper)->stamp(storage_path('app/private/contract.pdf'), $png, 2, 12.5, 78, 45). - Save the returned string with
Storage::disk('private')->put('signed/contract.pdf', $bytes).
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.