The logo, signature, stamp and cancelled watermark were placed with ExcelJS two-cell (tl/br) anchors, which stretch each image to fill a cell range — distorting them and making the watermark text small/squished. The PDF looked fine because CSS sizes by aspect. - New lib/image-size.ts: getImageSize (PNG/JPEG/WebP header parse) + scaleToBox. - Export route now places each image with a oneCell `tl` + pixel `ext`, aspect preserved and matched to the PDF sizes (logo ≤96×52, signature ≤165×44, stamp ≤80×66, watermark ≤880×720). - Watermark regenerated as a landscape canvas with the text filling it, so it spans the page like the PDF instead of sitting small in the centre. - Unit test for getImageSize + scaleToBox. Verified structurally: generated XLSX uses oneCellAnchors with fixed pixel ext sizes (49×52 / 45×44 / 67×66 / 880×629), not stretched cell ranges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
46 lines
2.1 KiB
TypeScript
46 lines
2.1 KiB
TypeScript
// Image dimension helpers used to size XLSX floating images by pixels with the
|
|
// aspect ratio preserved. ExcelJS's two-cell (tl/br) anchoring otherwise stretches
|
|
// an image to fill a cell range, which distorts logos / signatures / stamps.
|
|
|
|
/** Read pixel dimensions from a PNG / JPEG / WebP buffer (header parse, no deps). */
|
|
export function getImageSize(buf: Buffer): { width: number; height: number } | null {
|
|
// PNG — IHDR width/height at byte offsets 16 / 20
|
|
if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
}
|
|
// JPEG — scan segments for a Start-Of-Frame marker
|
|
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
let o = 2;
|
|
while (o + 9 < buf.length) {
|
|
if (buf[o] !== 0xff) { o++; continue; }
|
|
const m = buf[o + 1];
|
|
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
|
|
return { height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7) };
|
|
}
|
|
o += 2 + buf.readUInt16BE(o + 2);
|
|
}
|
|
}
|
|
// WebP — RIFF container, VP8 / VP8L / VP8X
|
|
if (buf.length >= 30 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") {
|
|
const fmt = buf.toString("ascii", 12, 16);
|
|
if (fmt === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
|
if (fmt === "VP8L") { const b = buf.readUInt32LE(21); return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 }; }
|
|
if (fmt === "VP8X") {
|
|
return {
|
|
width: 1 + ((buf[24] | (buf[25] << 8) | (buf[26] << 16)) & 0xffffff),
|
|
height: 1 + ((buf[27] | (buf[28] << 8) | (buf[29] << 16)) & 0xffffff),
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Scale natural dimensions to fit within a max box (px), preserving aspect ratio. */
|
|
export function scaleToBox(
|
|
natural: { width: number; height: number },
|
|
maxW: number,
|
|
maxH: number
|
|
): { width: number; height: number } {
|
|
const s = Math.min(maxW / natural.width, maxH / natural.height);
|
|
return { width: Math.round(natural.width * s), height: Math.round(natural.height * s) };
|
|
}
|