Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 94 additions & 18 deletions src/pages/Watermark/Watermark.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export function Watermark() {
offsetY: 0
});

const [watermarkMode, setWatermarkMode] = useState("text");
const [watermarkImage, setWatermarkImage] = useState(null);
const [watermarkImagePreview, setWatermarkImagePreview] = useState(null);

const {
isPremium,
hasReachedGlobalLimit,
Expand All @@ -51,15 +55,14 @@ export function Watermark() {
}, [file]);

useEffect(() => {
if (!file || !watermarkText.trim()) {
const t = setTimeout(() => setPreviewUrl(null), 0);
return () => clearTimeout(t);
}

const timer = setTimeout(async () => {
if (!file) { setPreviewUrl(null); return; }
if (watermarkMode === "text" && !watermarkText.trim()) { setPreviewUrl(null); return; }
if (watermarkMode === "image" && !watermarkImage) { setPreviewUrl(null); return; }

try {
setIsProcessing(true);
const blob = await addWatermark(file, watermarkText, options);
const blob = await addWatermark(file, watermarkText, options, watermarkMode === "image" ? watermarkImage : null);
const url = URL.createObjectURL(blob);

if (lastUrlRef.current) URL.revokeObjectURL(lastUrlRef.current);
Expand All @@ -73,7 +76,7 @@ export function Watermark() {
}, 500);

return () => clearTimeout(timer);
}, [file, watermarkText, options]);
}, [file, watermarkText, options, watermarkMode, watermarkImage]);

// 3. Handle cleanup on unmount
useEffect(() => {
Expand Down Expand Up @@ -111,6 +114,8 @@ export function Watermark() {
setPreviewUrl(null);
setOriginalPreviewUrl(null);
setPageCount(0);
setWatermarkImage(null);
setWatermarkImagePreview(null);
};

const updateOption = (key, value) => {
Expand Down Expand Up @@ -159,13 +164,73 @@ export function Watermark() {
</div>

<div className="space-y-2">
<label className="block text-sm font-medium text-zinc-400">Watermark Text</label>
<input
type="text"
value={watermarkText}
onChange={(e) => setWatermarkText(e.target.value)}
className="w-full h-11 px-4 bg-black border border-white/10 text-white rounded-lg uppercase outline-none focus:ring-1 focus:ring-white/20"
/>
<label className="block text-sm font-medium text-zinc-400">Watermark Type</label>
<div className="grid grid-cols-2 gap-2">
{["text", "image"].map((mode) => (
<button
key={mode}
onClick={() => {
setWatermarkMode(mode);
updateOption("rotation", mode === "text" ? 45 : 0);
}}
className={`h-9 rounded-lg text-sm font-medium capitalize transition-all ${
watermarkMode === mode
? "bg-white text-black"
: "bg-zinc-900 border border-white/10 text-zinc-400 hover:text-white"
}`}
>
{mode === "text" ? "Text" : "Image"}
</button>
))}
</div>
{watermarkMode === "text" && (
<div className="space-y-2">
<label className="block text-sm font-medium text-zinc-400">Watermark Text</label>
<input
type="text"
value={watermarkText}
onChange={(e) => setWatermarkText(e.target.value)}
className="w-full h-11 px-4 bg-black border border-white/10 text-white rounded-lg uppercase outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
)}
{watermarkMode === "image" && (
<div className="space-y-2">
<label className="block text-sm font-medium text-zinc-400">Watermark Image</label>
{!watermarkImage ? (
<Dropzone
onFilesSelected={(files) => {
const f = files[0];
if (!f) return;
setWatermarkImage(f);
setWatermarkImagePreview(URL.createObjectURL(f));
}}
multiple={false}
text="Click or drop a PNG / JPG"
accept="image/png, image/jpeg, image/jpg"
hintText="PNG, JPG only"
/>
) : (
<div className="relative flex items-center gap-3 p-3 bg-zinc-900/50 border border-white/10 rounded-xl">
<img
src={watermarkImagePreview}
alt="Watermark preview"
className="w-12 h-12 object-contain rounded-lg bg-zinc-800"
/>
<div className="flex-1 overflow-hidden">
<p className="text-sm text-zinc-200 truncate">{watermarkImage.name}</p>
<p className="text-xs text-zinc-500">{(watermarkImage.size / 1024).toFixed(1)} KB</p>
</div>
<button
onClick={() => { setWatermarkImage(null); setWatermarkImagePreview(null); }}
className="p-1.5 text-zinc-500 hover:text-red-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
</div>

<div className="space-y-6 pt-6 border-t border-white/10">
Expand All @@ -190,10 +255,21 @@ export function Watermark() {
</div>

<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs text-zinc-500 uppercase">Size: {options.fontSize}px</label>
<input type="range" min="10" max="200" value={options.fontSize} onChange={(e) => updateOption("fontSize", parseInt(e.target.value))} className="w-full h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-white" />
</div>
{watermarkMode === "text" && (
<div className="space-y-2">
<label className="text-xs text-zinc-500 uppercase">Size: {options.fontSize}px</label>
<input type="range" min="10" max="200" value={options.fontSize} onChange={(e) => updateOption("fontSize", parseInt(e.target.value))} className="w-full h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-white" />
</div>
)}
{watermarkMode === "image" && (
<div className="space-y-2">
<label className="text-xs text-zinc-500 uppercase">Scale: {Math.round((options.imageScale ?? 0.4) * 100)}%</label>
<input type="range" min="0.05" max="0.9" step="0.05"
value={options.imageScale ?? 0.4}
onChange={(e) => updateOption("imageScale", parseFloat(e.target.value))}
className="w-full h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-white" />
</div>
)}
<div className="space-y-2">
<label className="text-xs text-zinc-500 uppercase">Opacity: {Math.round(options.opacity * 100)}%</label>
<input type="range" min="0.05" max="1" step="0.05" value={options.opacity} onChange={(e) => updateOption("opacity", parseFloat(e.target.value))} className="w-full h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-white" />
Expand Down
82 changes: 81 additions & 1 deletion src/services/pdf.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,31 @@ export const getPdfPageCount = async (file) => {
return pdf.getPageCount();
};

export const addWatermark = async (file, watermarkText = "CONFIDENTIAL", options = {}) => {
// Rotates an Image bytes before embedding
async function rotateImageBytes(imageFile, angleDeg) {
const bitmap = await createImageBitmap(imageFile);
const rad = (angleDeg * Math.PI) / 180;
const sin = Math.abs(Math.sin(rad));
const cos = Math.abs(Math.cos(rad));

const canvasW = Math.round(bitmap.width * cos + bitmap.height * sin);
const canvasH = Math.round(bitmap.width * sin + bitmap.height * cos);

const canvas = document.createElement("canvas");
canvas.width = canvasW;
canvas.height = canvasH;

const ctx = canvas.getContext("2d");
ctx.translate(canvasW / 2, canvasH / 2);
ctx.rotate(rad);
ctx.drawImage(bitmap, -bitmap.width / 2, -bitmap.height / 2);

const blob = await new Promise((res) => canvas.toBlob(res, "image/png"));
const bytes = await blob.arrayBuffer();
return { bytes, width: canvasW, height: canvasH };
}

export const addWatermark = async (file, watermarkText = "CONFIDENTIAL", options = {}, watermarkImage = null) => {
if (!file) throw new Error("Please provide a PDF file.");

const {
Expand All @@ -72,13 +96,68 @@ export const addWatermark = async (file, watermarkText = "CONFIDENTIAL", options
rotation = 45,
offsetX = 0,
offsetY = 0,
imageScale = 0.4,
} = options;

const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const helveticaFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const pages = pdfDoc.getPages();

if (watermarkImage) {
let embeddedImage, imgW, imgH;

if (rotation !== 0) {
const { bytes, width, height } = await rotateImageBytes(watermarkImage, rotation);
embeddedImage = await pdfDoc.embedPng(bytes);
imgW = width;
imgH = height;
} else {
const imgBytes = await watermarkImage.arrayBuffer();
embeddedImage = watermarkImage.type === "image/png"
? await pdfDoc.embedPng(imgBytes)
: await pdfDoc.embedJpg(imgBytes);
const size = embeddedImage.size();
imgW = size.width;
imgH = size.height;
}

pages.forEach((page) => {
const { width: pageW, height: pageH } = page.getSize();
const targetW = pageW * imageScale;
const scale = targetW / imgW;
const targetH = imgH * scale;

const margin = 40;
let x, y;

switch (position) {
case "top-left":
x = margin; y = pageH - margin - targetH; break;
case "top-right":
x = pageW - margin - targetW; y = pageH - margin - targetH; break;
case "bottom-left":
x = margin; y = margin; break;
case "bottom-right":
x = pageW - margin - targetW; y = margin; break;
case "center":
default:
x = (pageW - targetW) / 2;
y = (pageH - targetH) / 2;
break;
}

page.drawImage(embeddedImage, {
x: x + Number(offsetX),
y: y + Number(offsetY),
width: targetW,
height: targetH,
opacity: parseFloat(opacity),
})
})

} else {

pages.forEach((page) => {
const { width, height } = page.getSize();
const textWidth = helveticaFont.widthOfTextAtSize(watermarkText, fontSize);
Expand Down Expand Up @@ -124,6 +203,7 @@ export const addWatermark = async (file, watermarkText = "CONFIDENTIAL", options
rotate: degrees(rotation),
});
});
}

const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes], { type: "application/pdf" });
Expand Down
Loading