From 65a9335de128cd8f815ed942f95d8c1337bdf4da Mon Sep 17 00:00:00 2001 From: Hardik Date: Sun, 21 Jun 2026 15:35:09 +0530 Subject: [PATCH] fix(po): keep the export stamp clear of the signature (no overlap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploaded signatures/stamps aren't always transparent PNGs, so an opaque stamp overlapping the signature/name would cover them. Extract the signatory-block geometry into a tested helper (signatoryLayout): the signature is centred over the name and the stamp sits to its RIGHT with a 10px gap — never overlapping. - lib/po-export-layout.ts (signatoryLayout) + unit test - export route uses it instead of inline overlap math Verified in a real export: signature 175-328px (centred), stamp 338-405px (10px gap, no overlap), stamp drawn behind the signature. Co-Authored-By: Claude Opus 4.8 --- App/app/api/po/[id]/export/route.ts | 17 +++++------ App/lib/po-export-layout.ts | 32 ++++++++++++++++++++ App/tests/unit/po-export-layout.test.ts | 39 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 App/lib/po-export-layout.ts create mode 100644 App/tests/unit/po-export-layout.test.ts diff --git a/App/app/api/po/[id]/export/route.ts b/App/app/api/po/[id]/export/route.ts index beff233..054a9e4 100644 --- a/App/app/api/po/[id]/export/route.ts +++ b/App/app/api/po/[id]/export/route.ts @@ -6,6 +6,7 @@ import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po"; import { downloadBuffer } from "@/lib/storage"; import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark"; import { getImageSize, scaleToBox } from "@/lib/image-size"; +import { signatoryLayout } from "@/lib/po-export-layout"; // ── Company fallback constants (used when no company is linked to a PO) ────── @@ -476,27 +477,25 @@ export async function GET(request: NextRequest, { params }: Props) { }; const sigExt = signatureBase64 ? scaleToBox(signatureSize ?? { width: 360, height: 96 }, 165, 44) : null; - const sigLeft = sigExt ? Math.round((SIG_BLOCK_PX - sigExt.width) / 2) : 0; // centred over the name + const stampExt = stampImg ? scaleToBox(stampImg, 80, 66) : null; + // Signature centred over the name; stamp to its RIGHT with a gap (no overlap). + const { sigLeft, stampLeft } = signatoryLayout({ blockPx: SIG_BLOCK_PX, sig: sigExt, stamp: stampExt }); - // Stamp / seal — drawn FIRST so it sits BEHIND the signature, tucked to its right. - if (stampImg) { - const stampExt = scaleToBox(stampImg, 80, 66); - const stampLeft = sigExt - ? Math.min(SIG_BLOCK_PX - stampExt.width, sigLeft + sigExt.width - Math.round(stampExt.width * 0.35)) - : SIG_BLOCK_PX - stampExt.width - 6; + // Stamp / seal — drawn FIRST so it layers BEHIND the signature if they ever touch. + if (stampImg && stampExt && stampLeft != null) { const stampId = wb.addImage({ base64: stampImg.base64, extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png", }); ws.addImage(stampId, { - tl: anchorAt(Math.max(0, stampLeft), SIG_ROW - 1), + tl: anchorAt(stampLeft, SIG_ROW - 1), ext: stampExt, editAs: "oneCell", }); } // Approver signature — drawn AFTER the stamp (on top), centred over the name. - if (signatureBase64 && sigExt) { + if (signatureBase64 && sigExt && sigLeft != null) { const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png"; const imgId = wb.addImage({ base64: signatureBase64, extension: imgType }); ws.addImage(imgId, { diff --git a/App/lib/po-export-layout.ts b/App/lib/po-export-layout.ts new file mode 100644 index 0000000..5f8bec8 --- /dev/null +++ b/App/lib/po-export-layout.ts @@ -0,0 +1,32 @@ +// Geometry for the exported PO's left signatory block (cols A-D). +// The approver signature is centred over the name; the company stamp/seal sits to +// its RIGHT with a gap so it never overlays the signature or name — important +// because uploaded signatures/stamps aren't always transparent PNGs. + +export interface Size { width: number; height: number } + +export interface SignatoryLayout { + sigLeft: number | null; // px from the block's left edge, or null when no signature + stampLeft: number | null; // px from the block's left edge, or null when no stamp +} + +export function signatoryLayout(opts: { + blockPx: number; + sig: Size | null; + stamp: Size | null; + gap?: number; +}): SignatoryLayout { + const gap = opts.gap ?? 10; + const sigLeft = opts.sig ? Math.round((opts.blockPx - opts.sig.width) / 2) : null; // centred + + let stampLeft: number | null = null; + if (opts.stamp) { + stampLeft = + sigLeft != null && opts.sig + ? Math.min(opts.blockPx - opts.stamp.width, sigLeft + opts.sig.width + gap) // clear of the signature + : opts.blockPx - opts.stamp.width - 6; // no signature → right-align in the block + stampLeft = Math.max(0, stampLeft); + } + + return { sigLeft, stampLeft }; +} diff --git a/App/tests/unit/po-export-layout.test.ts b/App/tests/unit/po-export-layout.test.ts new file mode 100644 index 0000000..2b1e933 --- /dev/null +++ b/App/tests/unit/po-export-layout.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { signatoryLayout } from "@/lib/po-export-layout"; + +const BLOCK = 503; // px width of the A-D signatory block + +describe("signatoryLayout", () => { + it("centres the signature in the block", () => { + const { sigLeft } = signatoryLayout({ blockPx: BLOCK, sig: { width: 153, height: 44 }, stamp: null }); + expect(sigLeft).not.toBeNull(); + expect(sigLeft! + 153 / 2).toBeCloseTo(BLOCK / 2, 0); // centre ≈ block centre + }); + + it("places the stamp to the RIGHT of the signature with no overlap", () => { + const sig = { width: 153, height: 44 }; + const stamp = { width: 67, height: 66 }; + const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp, gap: 10 }); + expect(stampLeft! ).toBeGreaterThanOrEqual(sigLeft! + sig.width); // starts at/after signature ends + expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK); // stays inside the block + }); + + it("never overlaps even with the widest signature + stamp", () => { + const sig = { width: 165, height: 44 }; // scaleToBox caps + const stamp = { width: 80, height: 66 }; // scaleToBox caps + const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp }); + expect(stampLeft!).toBeGreaterThanOrEqual(sigLeft! + sig.width); + expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK); + }); + + it("right-aligns the stamp when there is no signature", () => { + const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig: null, stamp: { width: 67, height: 66 } }); + expect(sigLeft).toBeNull(); + expect(stampLeft! + 67).toBeLessThanOrEqual(BLOCK); + expect(stampLeft!).toBeGreaterThan(BLOCK / 2); // on the right side + }); + + it("returns nulls when there are no images", () => { + expect(signatoryLayout({ blockPx: BLOCK, sig: null, stamp: null })).toEqual({ sigLeft: null, stampLeft: null }); + }); +}); -- 2.45.3