Drag & Drop a Signature on a PDF Using Laravel, FPDF, Vue & Fabric.js

Dr. Adam Nielsen

--

How to let users position a signature on a PDF and generate a properly aligned output in Laravel.

🎯 What You’ll Learn

  • Allow users to upload & position a signature with Vue & Fabric.js.
  • Convert canvas coordinates to PDF millimeters for accurate placement.
  • Generate a final PDF with Laravel & FPDF/FPDI.
  • Some configuration options with Fabric.js

Step 1: Setup Fabric.js for Dragging the Signature

We start with a certificate stored as a PDF document.
Since Fabric.js requires an image, we cannot place the signature directly onto the PDF. Instead, we:

  1. Convert the PDF to an image (JPG/PNG).
  2. Let users upload & position a signature on that image.
  3. Use the correct transformations to place the signature in the final PDF.

💡 Tip: You can use Adobe’s free PDF-to-JPG converter:
👉 Adobe PDF to JPG Converter

Step 2: Vue Form for Uploading the Signature

We create a Vue form component that:

  • Allows signature uploads
  • Displays a preview with drag & drop
  • Sends coordinates & size for proper PDF placement

SignatureForm.vue

<script setup lang="ts">
import {useForm} from "@inertiajs/vue3";
import Signature from "./Signature.vue";

const form = useForm({
signature: null,
trainer: null,
y_pos: null,
width: null,
});

const updateSignaturePosition = (position) => {
form.y_pos = position.y_mm;
form.width = position.width_mm;
}

const submit = () => { form.post(route('your.route.to.store..'));}
</script>

<template>
<form @submit.prevent="submit" >
<input type="file" v-model=form.signature">
<Signature :image="form.signature" v-show="form.signature" @transformed="updateSignaturePosition" />
</form>
</template>

What Happens Here?

  • Users upload a signature (PNG file).
  • The Signature component allows them to drag & resize the signature.
  • The transformed Y position & width are sent to the Laravel backend.

Step 3: Draggable Signature on the PDF (Fabric.js)

Now here is the exciting component for where we will be able to drag the signature on. I added some comments in the code.

<script setup>
import {ref, onMounted, watch} from "vue";
import * as fabric from "fabric";

const props = defineProps({
image: String,
});

const canvasRef = ref(null);
let canvas;

onMounted(() => {
canvas = new fabric.Canvas(canvasRef.value, {
selection: false,
});

// create image from imageData and send to callback
const loadImageManually = (imageData, callback) => {
const img = new Image();
img.src = imageData;
img.onload = () => {
const fabricImg = new fabric.Image(img);
callback(fabricImg);
};
img.onerror = () => console.error("Could not load image:", imageData);
};

// On mount, this will be called with the jpg verison of the pdf
// and set to the background of the canvis.
// Note, the canvis width/height is set to the image
// so it has same ratio as the pdf/image of our certificate.
const pdfImage = "/storage/example_certificate.jpg";
loadImageManually(pdfImage, (fabricImg) => {
console.log("PROBIERE HINTERGRUND ZU LADEN")
fabricImg.set({
selectable: false,
evented: false,
});

canvas.setWidth(fabricImg.width);
canvas.setHeight(fabricImg.height);

// ✅ Correct way to set background image in Fabric.js 6+
canvas.set("backgroundImage", fabricImg);
canvas.requestRenderAll();
});

canvas.on("object:modified", () => {
emitSignatureTransform();
});

});

const emitSignatureTransform = () => {
// Canvis size is 1024x726px, PDF size is 297x210mm
// Convert px to appropirate mm size in pdf
const y_mm = (obj.top / 726) * 210;
const width_mm = (obj.getScaledWidth() / 1024) * 297;

emit("transformed", {
y_mm,
width_mm
});
}


// When a user selects an image in input element, it should
// be directly draggable
watch(() => props.image, (newImage) => {
if (newImage) {
handleSignatureUpload(newImage);
}
});

const emit = defineEmits(["transformed"]);


// Add new signatture to canvas
const handleSignatureUpload = (imageSrc) => {

// Datei in Base64 oder Blob-URL umwandeln
const reader = new FileReader();
reader.onload = (e) => {
loadImageOntoCanvas(e.target.result);
};
reader.readAsDataURL(imageSrc);

};

const loadImageOntoCanvas = (src) => {
const imgElement = new Image();
imgElement.src = src;

imgElement.onload = () => {
const fabricImg = new fabric.Image(imgElement);

// 🛠 remove older signatures
const existingSignature = canvas.getObjects().find((o) => o.type === "image" && o !== canvas.backgroundImage);
if (existingSignature) {
canvas.remove(existingSignature);
console.log("Previous signature removed");
}

const signatureWidth = 250;
const signatureHeight = (signatureWidth / fabricImg.width) * fabricImg.height;
const signatureX = (canvas.width - signatureWidth) / 2; //
const signatureY = 608 - signatureHeight;

fabricImg.scaleToWidth(signatureWidth);
fabricImg.set({
left: signatureX,
top: signatureY,
cornerSize: 10,
lockRotation: true,
lockScalingFlip: true,
lockMovementX: true,
});

fabricImg.setControlsVisibility({ mtr: false, ml: false, mr: false, mt: false, mb: false });

fabricImg.on("scaling", () => {
fabricImg.set({ left: (canvas.width - fabricImg.getScaledWidth()) / 2 });
canvas.renderAll();
});

canvas.add(fabricImg);

emitSignatureTransform()
};
};




</script>

<template>
<div class="relative w-full ">
<canvas ref="canvasRef" ></canvas>
</div>
</template>

And this is how it looks if a signature was picked (not yet uploaded):

The signature appears on the certificate and can be moved around.

Step 4: Store Signature in Laravel

Once the user positions the signature, we send it to the backend.

public function store(Request $request)
{
$request->validate([
'trainer' => 'required|exists:users,id',
'y_pos' => 'required|numeric',
'width' => 'required|numeric',
'signature' => 'required|mimes:png|max:2048', // Only allow PNG files, max 2MB
]);

$trainer = $request->input('trainer');
$y_pos = $request->input('y_pos');
$width = $request->input('width');

/** @var UploadedFile $signature */
$signature = $request->file('signature');
$signaturePath = $signature->store('signatures', 'local');

Signature::updateOrCreate(
[
'user_id' => $trainer,
],
[
'y_pos' => $y_pos,
'width' => $width,
'signature_path' => $signaturePath,
]
);
}

Step 5: Generate PDF with FPDF

Now, we place the signature on the PDF using stored values using fpdf:

$pdf->Image(
$signature->getImagePath(),
$centeredXPosition,
$signature->y_pos,
$signature->width,
0, // Height Auto
'PNG'
);

I would like to make some additional comments on the signature.vue component, to explain some details.

Understanding the Coordinate Conversion: From Pixels to Millimeters

const emitSignatureTransform = () => {
// Canvis size is 1024x726px, PDF size is 297x210mm
// Convert px to appropirate mm size in pdf
const y_mm = (obj.top / 726) * 210;
const width_mm = (obj.getScaledWidth() / 1024) * 297;

emit("transformed", {
y_mm,
width_mm
});
}

What happens here, and where do the magic numbers come from?

My PDF has a size of 297mm × 210mm, but the image displayed on the website is in pixels, not millimeters. The JPG version of my PDF has dimensions of 1024px × 726px, maintaining the same aspect ratio as the original PDF. However, since pixels and millimeters are not directly comparable, we need a conversion method.

obj.getScaledWidth() returns the current width of the signature in pixels. To determine its corresponding width in millimeters on the PDF, we calculate its relative proportion of the total image width: obj.getScaledWidth()/1024

Multiplying this by 297mm (the total width of the PDF) gives us the signature width in millimeters. The same logic applies to the Y position:

210 * obj.top/726

Here, obj.top is the signature's vertical position in pixels, and by applying the same proportional scaling to 210mm (the height of the PDF), we get the correct placement in millimeters.

If your PDF or image has different dimensions, you’ll need to adjust these values accordingly. DPI does not matter for this calculation — we are only working with relative scaling between pixels and millimeters to ensure accurate placement.

Avoid duplicate signatures

imgElement.onload = () => {
const fabricImg = new fabric.Image(imgElement);

// 🛠 remove older signatures
const existingSignature = canvas.getObjects().find((o) => o.type === "image" && o !== canvas.backgroundImage);
if (existingSignature) {
canvas.remove(existingSignature);
console.log("Previous signature removed");
}

This handles the scenario where a user selects an image and then chooses another one without refreshing the page. Without removing the previous signature, multiple images would accumulate on the canvas, which is not the intended behavior. The background PDF image remains unaffected because it is not added as a standard object on the canvas but is instead set as a background, which functions differently.

Special Settings needed for my scenario

fabricImg.set({
left: signatureX,
top: signatureY,
cornerSize: 10,
lockRotation: true,
lockScalingFlip: true,
lockMovementX: true,
});

fabricImg.setControlsVisibility({ mtr: false, ml: false, mr: false, mt: false, mb: false });

fabricImg.on("scaling", () => {
fabricImg.set({ left: (canvas.width - fabricImg.getScaledWidth()) / 2 });
canvas.renderAll();
});

In my case, lockRotation was useful because I wanted to disable rotation. However, by default, the rotation handle remains visible, allowing users to rotate the image. To fully prevent rotation, the mtr (middle top rotate) control needs to be hidden using setControlsVisibility:

fabricImg.setControlsVisibility({ mtr: false, ml: false, mr: false, mt: false, mb: false });

Additionally, I wanted to maintain the aspect ratio of the signature, preventing users from scaling only along the X or Y axis. To achieve this, I hid the border scaling controls, ensuring that resizing can only be done proportionally.

Since the signature should always remain centered horizontally, I set lockMovementX = true, allowing movement only along the Y-axis.

One issue with resizing is that when a user scales the image using the corner handles, it may shift slightly out of alignment. To automatically re-center the signature after scaling, I applied the following fix:

fabricImg.on("scaling", () => {
fabricImg.set({ left: (canvas.width - fabricImg.getScaledWidth()) / 2 });
canvas.renderAll();
});

This ensures that no matter how the user resizes the signature, it always stays perfectly centered.

--

--

Dr. Adam Nielsen
Dr. Adam Nielsen

Written by Dr. Adam Nielsen

PHD in math. and Laravel / Vue Full-Stack-Developer

No responses yet