I build a browser tool that resizes photos and signatures for Indian exam and job application forms. Most of the work is the obvious kind: a form says "under 20 KB", so you shrink the file until it fits. The case that caught me out was the opposite one, where a signature comes out too small for the form to accept.
Several notices set the signature as a window. IBPS RRB XV (Officers and Office Assistants), RCF Assistant Officer (Secretarial) and India Exim Bank SRD 2026 all ask for 140 × 60 px and 10 to 20 KB. TNPSC One Time Registration asks for 230 × 75 px, also 10 to 20 KB. So there is a floor as well as a ceiling, and the pixel size is fixed.
Quality 100 is not enough
My first assumption was that a file under the minimum just needs a higher JPEG quality. That works until quality is already at 100.
A signature is dark ink on white paper. At 140 × 60 there are only 8,400 pixels, and most of the 8 × 8 blocks the encoder works on are flat white. Those compress to almost nothing. On our sample signature, the IBPS size came to 6.2 KB at quality 100, and the TNPSC size to 8.4 KB. The encoder has nothing left to spend bytes on, so the minimum is out of reach from the quality setting alone.
The pixel count is also fixed by the notice, so scaling up is not an option. Adding noise or a border would get the bytes up, but it changes what the person checking the form sees. I wanted the file bigger without touching a single pixel.
Padding with comment segments
A JPEG is a sequence of marker segments. FF FE is a comment (COM) segment: two marker bytes, a two-byte length, then whatever you like. Decoders skip it. So the fix is to add comment segments until the file reaches the minimum, and leave the image data alone.
The padding goes just before the FF D9 end-of-image marker, so the file still ends the way a JPEG should. A segment's length field is 16 bits, so a large gap needs more than one segment. This is close to what ships:
export const padJpeg = (bytes: Uint8Array, targetBytes: number): Uint8Array => {
if (bytes.length >= targetBytes) return bytes;
if (bytes.at(-2) !== 0xff || bytes.at(-1) !== 0xd9) throw new Error("Invalid JPEG output");
let output = bytes;
let remaining = targetBytes - bytes.length;
while (remaining > 0) {
const segmentBytes = Math.min(remaining, 65_537);
if (segmentBytes < 4) {
// A segment needs 4 bytes of header; overshoot slightly instead.
remaining += 4;
continue;
}
const segment = new Uint8Array(segmentBytes);
segment[0] = 0xff;
segment[1] = 0xfe;
const declaredLength = segmentBytes - 2; // the length counts itself, not the marker
segment[2] = (declaredLength >>> 8) & 0xff;
segment[3] = declaredLength & 0xff;
const combined = new Uint8Array(output.length + segment.length);
combined.set(output.subarray(0, -2));
combined.set(segment, output.length - 2);
combined.set(output.subarray(-2), output.length - 2 + segment.length);
output = combined;
remaining -= segmentBytes;
}
return output;
};
The tests check that the output reaches the target size and still ends with FF D9, and that a file without FF D9 at the end is refused rather than padded into something broken.
Which KB?
The other half of the problem is that "10 to 20 KB" does not say how many bytes a KB is. Some upload screens count 1,024 bytes and some count 1,000. A file of 20,300 bytes is under 20 KB on one reading and over it on the other.
So the tool sets the maximum under the smaller reading and the minimum under the larger one:
// A maximum of `kb` KB, met under both the 1000- and the 1024-byte reading.
export const kbCeilingBytes = (kb: number) => Math.floor(kb * 1000);
// A minimum of `kb` KB, met under both readings.
export const kbFloorBytes = (kb: number) => Math.ceil(kb * 1024);
For 10 to 20 KB that gives a target of 10,240 to 20,000 bytes. A file in that range passes whichever way the portal counts. When a window is too narrow to satisfy both readings (exactly 50 KB, say), it falls back to 1,024 bytes per KB and the code records that it did.
The search before the padding
Padding is the last step. First the tool encodes the image at the exact pixel size and binary-searches the quality for the largest file that still fits under the maximum. It uses canvas.toBlob with JPEG output, which takes quality on a 0.01 grid, so about seven halvings settle it. If the best fit is under the minimum, it pads. If padding would push it over the maximum, it throws an error rather than hand back a file the form will reject.
What came out
I ran the presets in Chrome 153 on 25 September 2026 on a generated test image of a signature photographed on a phone, 3024 × 1512 px and 892 KB:
| Preset | Output | Quality | Padding added |
|---|---|---|---|
| IBPS, 140 × 60 px | 10.0 KB | 100 | 3.8 KB |
| TNPSC, 230 × 75 px | 10.0 KB | 100 | 1.6 KB |
| SSC, 472 × 157 px | 18.9 KB | 99 | none |
The SSC preset needs no padding because it has about nine times the pixels of the IBPS one. SSC CHSL and CPO 2026 give the signature as roughly 6.0 × 2.0 cm with no DPI, so the preset converts that at an assumed 200 DPI. That assumption is the weakest part of the setup, so the SSC page on the site spells it out.
Everything runs in the browser and nothing is uploaded, which matters when the file is someone's signature. If you want to try the floor case yourself, the tool is at FormAssets. Pick the IBPS preset, give it a clean signature, and it will tell you how many bytes it had to add.
Top comments (0)