Certificate Generator - Free Online Tool - Small Study Tools
100% Free · No Signup · Browser-Based
Certificate Generator
Design a certificate with 11 templates, drag any element to reposition it, add QR verification, and export print-ready PDF or PNG. Pro Mode adds bulk generation from CSV, custom fonts, dual signatures, and more.
100% Private — everything is generated in your browser
● Pro Version is Free for now
✓ All templates · Custom fonts · Backgrounds · QR verification · Bulk CSV generation · 300 DPI unlocked
DayNight
+
🔤 Font & Style
34
0
16
✅ Verification
Certificate ID
QR Code
You can add any link here — a verification page, your website, a PDF, anything. Scanning the QR code on the certificate will take whoever scans it straight to this link.
📑 Saved / Duplicates
📝 Certificate Text
📅 Date & Signature
Two Signatures (left & right)
Signature
✍️
Tip: upload a clear photo of the signature with a transparent background (PNG works best) so it sits cleanly on the certificate.
Right Signature
✍️
Tip: upload a clear photo of the signature with a transparent background (PNG works best) so it sits cleanly on the certificate.
🖼️ Logo
🏢
50
🖌️ Colors
Scroll for more options
🖼️ Certificate Preview
📊 Bulk Generation
Your sheet needs a Name column (and an optional Reason column), exactly like this:
Name
Reason
Maria Gomez
for excellence in mathematics
Alex Rivera
for perfect attendance
Sana Khan
for outstanding leadership
`);
w.document.close();
setTimeout(() => w.print(), 500);
toast('Sent to print! ⭐ Bookmark SmallStudyTools.com for future use — new free tools every week.', 5000);
}// ── EXPORT: PDF ───────────────────────────────────────────────
// Fixed: (1) the correct @pdf-lib/fontkit build is now loaded, and font files
// are fetched as WOFF (parseable by fontkit) instead of the WOFF2 that
// Google's CSS API returns — so the chosen fonts genuinely embed instead of
// silently falling back to Helvetica; (2) the PDF uses the SAME y-up layout
// numbers as the preview (stack, alignment, drag offsets, letter spacing),
// so the download finally matches the screen.
function hexToRgb(hex) {
const c = hex.replace('#','');
return rgb(parseInt(c.slice(0,2),16)/255, parseInt(c.slice(2,4),16)/255, parseInt(c.slice(4,6),16)/255);
}
const QR_DARK_RGB = rgb(17/255, 24/255, 39/255);
async function embedRealFont(pdfDoc, fontKey, weight, italic, useCustom) {
try {
if (typeof fontkit !== 'undefined' && pdfDoc.registerFontkit) pdfDoc.registerFontkit(fontkit);
if (useCustom && customFontBytes) {
const bytes = await (await fetch(customFontBytes)).arrayBuffer();
return await pdfDoc.embedFont(bytes, { subset: true });
}
if (fontKey === 'georgia' || fontKey === 'timesNewRoman') {
const sf = weight >= 600
? (italic ? StandardFonts.TimesRomanBoldItalic : StandardFonts.TimesRomanBold)
: (italic ? StandardFonts.TimesRomanItalic : StandardFonts.TimesRoman);
return await pdfDoc.embedFont(sf);
}
const bytes = await fetchFontBytes(fontKey, weight, italic);
if (!bytes) throw new Error('no font bytes');
return await pdfDoc.embedFont(bytes, { subset: true });
} catch (e) {
toast('Could not embed the selected font — using a close fallback in the PDF');
return await pdfDoc.embedFont(weight >= 600 ? StandardFonts.HelveticaBold : StandardFonts.Helvetica);
}
}
async function embedImgIfAny(pdfDoc, dataUrl) {
if (!dataUrl) return null;
try {
const bytes = await (await fetch(dataUrl)).arrayBuffer();
if (dataUrl.includes('image/png')) return await pdfDoc.embedPng(bytes);
return await pdfDoc.embedJpg(bytes);
} catch (e) { return null; }
}
function trackedWidth(font, text, size, tracking) {
if (!tracking) return font.widthOfTextAtSize(text, size);
let w = 0;
for (const ch of text) w += font.widthOfTextAtSize(ch, size);
return w + tracking * Math.max(0, text.length - 1);
}
function drawTrackedText(page, text, opts) {
if (!opts.tracking) { page.drawText(text, opts); return; }
let x = opts.x;
for (const ch of text) {
page.drawText(ch, { x, y: opts.y, size: opts.size, font: opts.font, color: opts.color, opacity: opts.opacity });
x += opts.font.widthOfTextAtSize(ch, opts.size) + opts.tracking;
}
}
async function buildCertPdfDoc(nameOverride, reasonOverride) {
const theme = getActiveTheme();
const qrOn = isPro && qrEnabled;
const L = computeLayout(currentPageSize, orientation, dualSignature, qrOn);
const f = getFields();
if (nameOverride) f.name = nameOverride;
if (reasonOverride !== undefined && reasonOverride !== '') f.reason = reasonOverride;const pdfDoc = await PDFDocument.create();
if (typeof fontkit !== 'undefined' && pdfDoc.registerFontkit) pdfDoc.registerFontkit(fontkit);
// Body text is DM Sans in the preview — embed the real thing (silent
// fallback to Helvetica if the fetch fails, e.g. offline).
let bodyFont;
try {
const b = await fetchFontBytes('dmSans', 400, false);
bodyFont = b ? await pdfDoc.embedFont(b, { subset: true }) : await pdfDoc.embedFont(StandardFonts.Helvetica);
} catch (e) { bodyFont = await pdfDoc.embedFont(StandardFonts.Helvetica); }
const titleFont = await embedRealFont(pdfDoc, theme.titleFontKey, 700, false, false);
const nameFont = await embedRealFont(pdfDoc, theme.nameFontKey, nameBold ? 700 : 500, nameItalic, !!customFontName);const page = pdfDoc.addPage([L.page.w, L.page.h]);
const textColor = hexToRgb(theme.text), accentColor = hexToRgb(theme.accent), borderColor = hexToRgb(theme.borderColor);page.drawRectangle({ x:0, y:0, width:L.page.w, height:L.page.h, color: hexToRgb(theme.bg) });// background image
const bgImg = await embedImgIfAny(pdfDoc, bgImageDataUrl);
if (bgImg) page.drawImage(bgImg, { x: 0, y: 0, width: L.page.w, height: L.page.h, opacity: 0.14 });// border
const b = BORDER_STYLES[currentBorder];
const m = 14;
if (b.style === 'single') page.drawRectangle({ x:m, y:m, width:L.page.w-2*m, height:L.page.h-2*m, borderColor, borderWidth: b.thin?0.75:1.6, borderDashArray: b.dash ? b.dash.split(',').map(Number) : undefined });
else if (b.style === 'double') {
page.drawRectangle({ x:m, y:m, width:L.page.w-2*m, height:L.page.h-2*m, borderColor, borderWidth:1.6, borderDashArray: b.dash ? b.dash.split(',').map(Number) : undefined });
page.drawRectangle({ x:m+7, y:m+7, width:L.page.w-2*(m+7), height:L.page.h-2*(m+7), borderColor, borderWidth:1 });
} else if (b.style === 'triple') {
[0,7,14].forEach((off2,i) => page.drawRectangle({ x:m+off2, y:m+off2, width:L.page.w-2*(m+off2), height:L.page.h-2*(m+off2), borderColor, borderWidth:i===1?0.8:1.6 }));
[[m+3,m+3],[L.page.w-m-3,m+3],[m+3,L.page.h-m-3],[L.page.w-m-3,L.page.h-m-3]].forEach(([x,y]) => {
page.drawCircle({ x, y, size: 3, color: borderColor });
});
}// logo (same position preset + drag offset as preview)
const logoImg = await embedImgIfAny(pdfDoc, logoDataUrl);
if (logoImg) {
let logoX = L.logo.x;
if (logoPosition === 'top-left') logoX = L.contentX;
else if (logoPosition === 'top-right') logoX = L.contentX + L.contentW - L.logo.w;
const o = off('logo');
page.drawImage(logoImg, { x: logoX + o.dx, y: L.logo.y - o.dy, width: L.logo.w, height: L.logo.h });
}// text stack — identical math to the preview
const nameText = truncate(f.name, 40);
const requestedNameSize = parseInt(fv('nameSizeRange')) || 34;
const tracking = parseFloat(fv('letterSpacingRange')) || 0;
let nameSize = requestedNameSize;
const maxNameWidth = L.contentW - 24;
while (nameSize > 18 && trackedWidth(nameFont, nameText, nameSize, tracking) > maxNameWidth) nameSize -= 1;
const lineGap = parseFloat(fv('lineHeightRange')) || 16;
const stack = computeTextStack(L, nameSize, lineGap / 16);// title
const titleText = truncate(f.title, 50);
const titleW = titleFont.widthOfTextAtSize(titleText, 22);
const to = off('title');
page.drawText(titleText, { x: pdfAlignedX(L, titleW) + to.dx, y: stack.titleY - to.dy, size: 22, font: titleFont, color: textColor });// name
const nameW = trackedWidth(nameFont, nameText, nameSize, tracking);
const no = off('name');
drawTrackedText(page, nameText, { x: pdfAlignedX(L, nameW) + no.dx, y: stack.nameY - no.dy, size: nameSize, font: nameFont, color: accentColor, tracking });// date
const dateText = formatDate(f.date);
if (dateText) {
const dateW = bodyFont.widthOfTextAtSize(dateText, 10);
const dofs = off('date');
page.drawText(dateText, { x: pdfAlignedX(L, dateW) + dofs.dx, y: stack.dateY - dofs.dy, size: 10, font: bodyFont, color: textColor, opacity: 0.75 });
}// reason / issuer
if (f.reason) {
const t = truncate(f.reason, 80), w = bodyFont.widthOfTextAtSize(t, 12), o = off('reason');
page.drawText(t, { x: pdfAlignedX(L, w) + o.dx, y: stack.subtitleY - o.dy, size: 12, font: bodyFont, color: textColor, opacity: 0.85 });
}
if (f.issuer) {
const t = truncate(f.issuer, 60), w = bodyFont.widthOfTextAtSize(t, 10.5), o = off('issuer');
page.drawText(t, { x: pdfAlignedX(L, w) + o.dx, y: stack.issuerY - o.dy, size: 10.5, font: bodyFont, color: textColor, opacity: 0.65 });
}// seal
const sealColor = overrideSealColor || theme.accent;
const so = off('seal');
drawSealPdf(page, L.badge.x + so.dx, L.badge.y - so.dy, L.badge.r, hexToRgb(sealColor), currentSeal);// signature(s): script text or image above the line, designation below
const sig1 = getSig(1, f), sig2 = dualSignature && L.sig2 ? getSig(2, f) : null;
let sigFont = null;
if ((sig1.mode === 'text' && sig1.text) || (sig2 && sig2.mode === 'text' && sig2.text)) {
try {
const sb = await fetchFontBytes(SIG_SCRIPT_FONT_KEY, 400, false);
sigFont = sb ? await pdfDoc.embedFont(sb, { subset: true }) : bodyFont;
} catch (e) { sigFont = bodyFont; }
}
const sigImg1 = sig1.mode === 'image' ? await embedImgIfAny(pdfDoc, sig1.image) : null;
const sigImg2 = sig2 && sig2.mode === 'image' ? await embedImgIfAny(pdfDoc, sig2.image) : null;
await drawSigPdf(page, L.sig, off('sig1'), bodyFont, textColor, sig1, sigFont, sigImg1);
if (sig2) await drawSigPdf(page, L.sig2, off('sig2'), bodyFont, textColor, sig2, sigFont, sigImg2);// verification
if (isPro && certIdEnabled) page.drawText(certId, { x: L.margin+4, y: 22, size: 8, font: bodyFont, color: textColor, opacity: 0.5 });
if (qrOn) {
const qrSize = 34, qrX = L.cx - qrSize/2, qrY = 20;
drawQrPdf(page, f.verifyUrl || 'https://smallstudytools.com', qrX, qrY, qrSize);
}return pdfDoc;
}
async function drawSigPdf(page, sig, o, bodyFont, textColor, sigData, sigFont, sigImg) {
const x = sig.x + o.dx, y = sig.y - o.dy;
// above the line: signature image or typed script text
if (sigData.mode === 'image' && sigImg) {
const maxW = sig.w * 0.85, maxH = 36;
const ratio = Math.min(maxW / sigImg.width, maxH / sigImg.height);
const iw = sigImg.width * ratio, ih = sigImg.height * ratio;
page.drawImage(sigImg, { x: x + (sig.w - iw) / 2, y: y + 3, width: iw, height: ih });
} else if (sigData.mode === 'text' && sigData.text && sigFont) {
const st = truncate(sigData.text, 26);
const sw = sigFont.widthOfTextAtSize(st, 19);
page.drawText(st, { x: x + sig.w/2 - sw/2, y: y + 7, size: 19, font: sigFont, color: textColor });
}
page.drawLine({ start:{x, y}, end:{x: x + sig.w, y}, thickness:1, color: textColor });
// below the line: designation
const lbl = truncate(sigData.designation, 28);
const lw = bodyFont.widthOfTextAtSize(lbl, 9);
page.drawText(lbl, { x: x + sig.w/2 - lw/2, y: y - 14, size: 9, font: bodyFont, color: textColor, opacity: 0.75 });
}
function drawSealPdf(page, cx, cy, r, colorRgb, sealKey) {
if (sealKey === 'sunburst') {
for (let i = 0; i < 18; i++) {
const angle = (i / 18) * Math.PI * 2;
const x1 = cx + Math.cos(angle) * (r - 2), y1 = cy + Math.sin(angle) * (r - 2);
const x2 = cx + Math.cos(angle) * (r + 14), y2 = cy + Math.sin(angle) * (r + 14);
page.drawLine({ start:{x:x1,y:y1}, end:{x:x2,y:y2}, thickness: 3.5, color: colorRgb, opacity: 0.85 });
}
} else if (sealKey === 'laurel') {
[-1, 1].forEach(side => {
for (let i = 0; i < 6; i++) {
const t = i / 5;
const angle = Math.PI * 0.5 + t * Math.PI * 0.42;
const leafR = r + 8 + t * 11;
const x = cx + side * Math.cos(angle) * leafR;
// preview fans leaves BELOW the medallion (y-down: cy + sin), so in
// PDF y-up coordinates that is cy − sin, with the rotation mirrored
const y = cy - Math.sin(angle) * leafR;
const rotation = side * (angle * 180/Math.PI - 90) + 90;
page.drawEllipse({ x, y, xScale: 7, yScale: 3.2, rotate: degrees(-rotation), color: colorRgb, opacity: 0.8 });
}
});
} else if (sealKey === 'ribbon') {
// tails hang DOWN below the medallion (y-up: below = smaller y)
const tailW = r * 0.55;
page.drawLine({ start:{x:cx-tailW*0.5,y:cy-r*0.3}, end:{x:cx-tailW*0.5,y:cy-r*1.55}, thickness: tailW, color: colorRgb, opacity: 0.85 });
page.drawLine({ start:{x:cx+tailW*0.5,y:cy-r*0.3}, end:{x:cx+tailW*0.5,y:cy-r*1.55}, thickness: tailW, color: colorRgb, opacity: 0.85 });
}
page.drawCircle({ x: cx, y: cy, size: r, borderColor: colorRgb, borderWidth: 2.5 });
page.drawCircle({ x: cx, y: cy, size: r-7, borderColor: colorRgb, borderWidth: 1, opacity: 0.6 });
page.drawCircle({ x: cx, y: cy, size: r-16, color: colorRgb, opacity: 0.10 });
}
function drawQrPdf(page, text, x, y, size) {
const { count, modules } = buildQrMatrix(text);
const cell = size / count;
page.drawRectangle({ x, y, width: size, height: size, color: rgb(1,1,1) });
modules.forEach(([r,c]) => { page.drawRectangle({ x: x+c*cell, y: y+size-(r+1)*cell, width: cell+0.3, height: cell+0.3, color: QR_DARK_RGB }); });
}
async function downloadPDF() {
try {
const pdfDoc = await buildCertPdfDoc();
const bytes = await pdfDoc.save();
triggerDownload(new Blob([bytes], { type:'application/pdf' }), 'smallstudytools-certificate.pdf');
toast('PDF downloaded! ⭐ Enjoying this tool? Bookmark SmallStudyTools.com and keep visiting — new free tools every week.', 5000);
} catch (e) { toast('PDF generation failed — please try again'); }
}// ── BULK GENERATION FROM CSV/EXCEL ───────────────────────────
function parseCSV(text) {
const lines = text.split(/\r?\n/).filter(l => l.trim());
if (!lines.length) return [];
const headers = lines[0].split(',').map(h => h.trim().toLowerCase());
const nameIdx = headers.indexOf('name');
const reasonIdx = headers.indexOf('reason');
if (nameIdx === -1) return [];
return lines.slice(1).map(line => {
const cols = line.split(',').map(c => c.trim());
return { name: cols[nameIdx] || '', reason: reasonIdx !== -1 ? (cols[reasonIdx] || '') : '' };
}).filter(r => r.name);
}
function parseExcelRows(rows) {
if (!rows.length) return [];
const headers = rows[0].map(h => String(h||'').trim().toLowerCase());
const nameIdx = headers.indexOf('name');
const reasonIdx = headers.indexOf('reason');
if (nameIdx === -1) return [];
return rows.slice(1).map(row => ({
name: String(row[nameIdx] || '').trim(),
reason: reasonIdx !== -1 ? String(row[reasonIdx] || '').trim() : '',
})).filter(r => r.name);
}
function onBulkFileUpload(file) {
if (!isPro) { proToast('Bulk generation'); return; }
if (!file) return;
const isExcel = /\.xlsx?$/i.test(file.name);
const reader = new FileReader();
reader.onload = e => {
try {
if (isExcel) {
const wb = XLSX.read(e.target.result, { type: 'array' });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
bulkFileRows = parseExcelRows(rows);
} else {
bulkFileRows = parseCSV(e.target.result);
}
if (!bulkFileRows.length) toast('No valid rows found — the file needs a "Name" column (download the sample CSV to see the format)', 4500);
updateBulkStatus();
} catch (err) {
bulkFileRows = [];
document.getElementById('bulkStatus').textContent = 'Could not read this file — check it matches the sample format.';
updateBulkButton();
}
};
if (isExcel) reader.readAsArrayBuffer(file); else reader.readAsText(file);
}
// ── Manual bulk table ─────────────────────────────────────────
function renderBulkTable() {
const wrap = document.getElementById('bulkTable');
if (!wrap) return;
wrap.innerHTML = bulkManualRows.map((r, i) => `
`).join('');
}
function onBulkCell(i, field, value) {
if (bulkManualRows[i]) { bulkManualRows[i][field] = value; updateBulkStatus(); }
}
function addBulkRow() {
if (!isPro) { proToast('Bulk generation'); return; }
bulkManualRows.push({ name: '', reason: '' });
renderBulkTable();
}
function removeBulkRow(i) {
bulkManualRows.splice(i, 1);
if (!bulkManualRows.length) bulkManualRows.push({ name: '', reason: '' });
renderBulkTable(); updateBulkStatus();
}
function getManualBulkRows() {
return bulkManualRows.map(r => ({ name: r.name.trim(), reason: r.reason.trim() })).filter(r => r.name);
}
function getAllBulkRows() { return [...getManualBulkRows(), ...bulkFileRows]; }
function updateBulkStatus() {
const status = document.getElementById('bulkStatus');
const manual = getManualBulkRows().length, fromFile = bulkFileRows.length, total = manual + fromFile;
if (status) {
if (!total) status.textContent = '';
else {
const parts = [];
if (manual) parts.push(`${manual} typed`);
if (fromFile) parts.push(`${fromFile} from file`);
status.textContent = `${parts.join(' + ')} — ${total} certificate${total !== 1 ? 's' : ''} ready to generate.`;
}
}
updateBulkButton();
}
function updateBulkButton() {
const btn = document.getElementById('bulkGenBtn');
if (btn) btn.disabled = getAllBulkRows().length === 0;
}
function downloadSampleCsv() {
const csv = 'Name,Reason\r\nMaria Gomez,for excellence in mathematics\r\nAlex Rivera,for perfect attendance\r\nSana Khan,for outstanding leadership\r\n';
triggerDownload(new Blob([csv], { type: 'text/csv;charset=utf-8' }), 'certificate-names-sample.csv');
toast('Sample CSV downloaded — fill it with your names and upload it back!', 4000);
}
async function downloadBulkZip() {
const rows = getAllBulkRows();
if (!rows.length) { toast('Type some names above or upload a CSV/Excel file first'); return; }
const zip = new JSZip();
const status = document.getElementById('bulkStatus');
try {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
status.textContent = `Generating ${i+1} of ${rows.length}...`;
if (certIdEnabled) certId = generateCertId();
const pdfDoc = await buildCertPdfDoc(row.name, row.reason);
const bytes = await pdfDoc.save();
const safeName = row.name.replace(/[^a-z0-9]/gi, '_').slice(0, 40);
zip.file(`smallstudytools-certificate-${safeName || (i+1)}.pdf`, bytes);
}
status.textContent = `Done — ${rows.length} certificates generated.`;
const zipBlob = await zip.generateAsync({ type: 'blob' });
triggerDownload(zipBlob, 'smallstudytools-certificates-bulk.zip');
toast(`${rows.length} certificates downloaded! ⭐ Bookmark SmallStudyTools.com — more free tools every week.`, 5000);
} catch (e) {
status.textContent = 'Bulk generation failed — please try again.';
toast('Bulk generation failed');
}
}// ── UNDO/REDO ─────────────────────────────────────────────────
// (Renamed from `history` — that shadowed window.history.)
function createHistoryStack(maxSize) {
let stack = [], pointer = -1;
return {
push(state) {
stack = stack.slice(0, pointer + 1);
stack.push(JSON.parse(JSON.stringify(state)));
if (stack.length > maxSize) stack.shift();
pointer = stack.length - 1;
},
undo() { if (pointer > 0) { pointer--; return stack[pointer]; } return null; },
redo() { if (pointer < stack.length - 1) { pointer++; return stack[pointer]; } return null; },
canUndo: () => pointer > 0,
canRedo: () => pointer < stack.length - 1,
};
}
const editHistory = createHistoryStack(60);
let restoringState = false;
function captureState() {
return {
title: fv('fieldTitle'), name: fv('fieldName'), reason: fv('fieldReason'), issuer: fv('fieldIssuer'),
date: fv('fieldDate'), sig1: fv('fieldSig1'), sig2: fv('fieldSig2'), verifyUrl: fv('fieldVerifyUrl'),
currentTemplate, currentPageSize, orientation, dualSignature, currentBorder, currentSeal, logoPosition,
overrideAccent, overrideText, overrideBg, overrideBorderColor, overrideSealColor, overrideTitleFont, overrideNameFont,
nameBold, nameItalic, textAlign, certIdEnabled, qrEnabled, certId,
sig1Mode: sigModes[1], sig2Mode: sigModes[2],
sig1SigText: fv('fieldSig1SigText'), sig2SigText: fv('fieldSig2SigText'),
elementOffsets: JSON.parse(JSON.stringify(elementOffsets)),
nameSize: fv('nameSizeRange'), letterSpacing: fv('letterSpacingRange'), lineHeight: fv('lineHeightRange'), logoSize: fv('logoSizeRange'),
};
}
function restoreState(s) {
if (!s) return;
restoringState = true;
const setv = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; };
setv('fieldTitle', s.title); setv('fieldName', s.name); setv('fieldReason', s.reason); setv('fieldIssuer', s.issuer);
setv('fieldDate', s.date); setv('fieldSig1', s.sig1); setv('fieldSig2', s.sig2); setv('fieldVerifyUrl', s.verifyUrl);
setv('nameSizeRange', s.nameSize || 34); setv('letterSpacingRange', s.letterSpacing || 0); setv('lineHeightRange', s.lineHeight || 16); setv('logoSizeRange', s.logoSize || 50);
currentTemplate = s.currentTemplate; currentPageSize = s.currentPageSize; orientation = s.orientation;
dualSignature = s.dualSignature; currentBorder = s.currentBorder; currentSeal = s.currentSeal || 'sunburst'; logoPosition = s.logoPosition || 'top-center';
overrideAccent = s.overrideAccent; overrideText = s.overrideText; overrideBg = s.overrideBg; overrideBorderColor = s.overrideBorderColor; overrideSealColor = s.overrideSealColor;
overrideTitleFont = s.overrideTitleFont; overrideNameFont = s.overrideNameFont;
nameBold = s.nameBold; nameItalic = s.nameItalic; textAlign = s.textAlign;
certIdEnabled = s.certIdEnabled; qrEnabled = s.qrEnabled; certId = s.certId;
sigModes = { 1: s.sig1Mode || 'text', 2: s.sig2Mode || 'text' };
setv('fieldSig1SigText', s.sig1SigText); setv('fieldSig2SigText', s.sig2SigText);
elementOffsets = s.elementOffsets ? JSON.parse(JSON.stringify(s.elementOffsets)) : {};
document.getElementById('dualSigToggle').checked = dualSignature;
document.getElementById('certIdToggle').checked = certIdEnabled;
document.getElementById('qrToggle').checked = qrEnabled;
document.getElementById('nameSizeVal').textContent = fv('nameSizeRange');
document.getElementById('letterSpacingVal').textContent = fv('letterSpacingRange');
document.getElementById('lineHeightVal').textContent = fv('lineHeightRange');
document.getElementById('logoSizeVal').textContent = fv('logoSizeRange');
restoringState = false;
renderAllUI();
}
function pushHistory() { if (!restoringState) { editHistory.push(captureState()); updateUndoRedoButtons(); } }
function updateUndoRedoButtons() {
document.getElementById('undoBtn').disabled = !editHistory.canUndo();
document.getElementById('redoBtn').disabled = !editHistory.canRedo();
}
function doUndo() { const s = editHistory.undo(); if (s) { restoreState(s); updateUndoRedoButtons(); toast('Undone'); } }
function doRedo() { const s = editHistory.redo(); if (s) { restoreState(s); updateUndoRedoButtons(); toast('Redone'); } }// ── DUPLICATE / SAVE ──────────────────────────────────────────
function duplicateCertificate() {
const label = prompt('Name this saved certificate:', fv('fieldName') || 'My Certificate');
if (!label) return;
savedCerts.unshift({ id: 'c'+Date.now(), label, state: captureState() });
if (savedCerts.length > 12) savedCerts.pop();
renderSavedList();
toast('Duplicated and saved!');
}
function renderSavedList() {
const wrap = document.getElementById('savedList');
if (!savedCerts.length) { wrap.innerHTML = '
No saved certificates yet.
'; return; }
wrap.innerHTML = savedCerts.map(c => `
`).join('');
}
function loadSaved(id) { const c = savedCerts.find(x => x.id === id); if (c) { restoreState(c.state); pushHistory(); toast(`Loaded "${c.label}"`); } }
function deleteSaved(id) { savedCerts = savedCerts.filter(c => c.id !== id); renderSavedList(); }// ── SHARE ─────────────────────────────────────────────────────
// On phones/modern browsers: native share sheet WITH the certificate image
// attached (WhatsApp, Instagram, email — whatever the device offers).
// Fallback everywhere else: a chip menu covering all major platforms.
const SHARE_URL = 'https://smallstudytools.com/certificate-generator/';
const SHARE_TEXT = 'I made this certificate free with the SmallStudyTools Certificate Generator — 11 templates, QR verification, bulk generation, 100% private:';
async function shareCertificate() {
try {
const { svgStr, page } = await buildExportSvgString();
const canvas = await svgToCanvas(svgStr, page.w, page.h, 2);
const blob = await new Promise(r => canvas.toBlob(r, 'image/png'));
const file = new File([blob], 'certificate.png', { type: 'image/png' });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file], title: 'My Certificate', text: SHARE_TEXT, url: SHARE_URL });
toast('Shared! ⭐ Bookmark SmallStudyTools.com for future use.', 4500);
return;
}
} catch (e) {
if (e && e.name === 'AbortError') return; // user closed the share sheet
}
toggleShareMenu();
}
// shares the TOOL PAGE itself (not a certificate image) — used by the
// hero's "Share This Tool" button, reusing the same URL/text and the
// same fallback menu as shareCertificate(), just without the image step
async function sharePageTool() {
if (navigator.share) {
try {
await navigator.share({ title: 'Certificate Generator — SmallStudyTools', text: SHARE_TEXT, url: SHARE_URL });
return;
} catch (e) {
if (e && e.name === 'AbortError') return;
}
}
toggleShareMenu();
}
function renderShareMenu() {
const el = document.getElementById('shareMenu');
if (!el) return;
const u = encodeURIComponent(SHARE_URL), t = encodeURIComponent(SHARE_TEXT);
el.innerHTML = [
`WhatsApp`,
`Facebook`,
`Twitter / X`,
`LinkedIn`,
`Telegram`,
`Pinterest`,
`Reddit`,
`Email`,
``,
].join('');
}
function toggleShareMenu() {
const el = document.getElementById('shareMenu');
if (!el) return;
document.getElementById('moreMenu')?.classList.remove('open');
el.classList.toggle('open');
}
function renderMoreMenu() {
const el = document.getElementById('moreMenu');
if (!el) return;
el.innerHTML = `
`;
}
function toggleMoreMenu() {
const el = document.getElementById('moreMenu');
if (!el) return;
if (!el.innerHTML.trim()) renderMoreMenu();
document.getElementById('shareMenu')?.classList.remove('open');
el.classList.toggle('open');
}
function copyShareLink() {
navigator.clipboard.writeText(SHARE_URL)
.then(() => toast('Link copied! Share it anywhere you like 🎉'))
.catch(() => toast('Could not copy — long-press the address bar to copy the link'));
}
document.addEventListener('click', e => {
const share = document.getElementById('shareMenu');
if (share && share.classList.contains('open') && !share.contains(e.target) && !e.target.closest('.ft-btn')) share.classList.remove('open');
const more = document.getElementById('moreMenu');
if (more && more.classList.contains('open') && !more.contains(e.target) && !e.target.closest('.ft-btn')) more.classList.remove('open');
});// ── MODE (Simple/Pro) ──────────────────────────────────────────
function setMode(mode) {
isPro = mode === 'pro';
document.getElementById('btnSimple').classList.toggle('active', !isPro);
document.getElementById('btnPro').classList.toggle('active', isPro);
document.body.classList.toggle('pro-mode', isPro);
const pb = document.getElementById('proBar'); if (pb) pb.style.display = isPro ? 'flex' : 'none';
if (!isPro) {
if (TEMPLATES[currentTemplate].pro) { currentTemplate = 'classicGold'; currentBorder = TEMPLATES.classicGold.border; }
dualSignature = false; overrideBorderColor = null;
document.getElementById('dualSigToggle').checked = false;
}
renderMoreMenu();
renderAllUI();
}// ── INIT ──────────────────────────────────────────────────────
document.getElementById('fieldDate').value = new Date().toISOString().slice(0,10);
renderMoreMenu();// hide the "scroll for more" hint once the panel is already scrolled to
// the bottom, or if everything fits without scrolling in the first place
function updatePanelScrollHint(){
const p = document.getElementById('leftPanel');
const hint = document.getElementById('panelScrollHint');
if(!p || !hint) return;
const needsScroll = p.scrollHeight > p.clientHeight + 4;
const atBottom = p.scrollTop + p.clientHeight >= p.scrollHeight - 4;
hint.classList.toggle('hidden', !needsScroll || atBottom);
}
document.getElementById('leftPanel')?.addEventListener('scroll', updatePanelScrollHint);
window.addEventListener('resize', updatePanelScrollHint);
setTimeout(updatePanelScrollHint, 300);// ── favorite THIS page — uses the shared favorites engine directly
// (SSTFavorites.toggle) since this is a standalone page, not a tool card
// wrapped in a link the way the homepage sections are. The icon is baked
// in as a fully self-contained snippet (same real icon + gradient shown
// everywhere else this tool appears) so it renders correctly in the
// drawer regardless of which page someone opens it from. ──
const CERT_FAV_URL = 'https://smallstudytools.com/certificate-generator/';
function setPageFavLabel(isFav){
const label = document.getElementById('pageFavHeartLabel');
if(label) label.textContent = isFav ? 'Your Favorite' : 'Add to Favorites';
}
function togglePageFavorite(){
if(typeof SSTFavorites === 'undefined') return; // shared engine not loaded on this page
const icon = '';
const nowFav = SSTFavorites.toggle(CERT_FAV_URL, {name:'Certificate Generator', url:CERT_FAV_URL, icon:icon});
const btn = document.getElementById('pageFavHeart');
if(btn){
btn.classList.toggle('active', nowFav);
if(nowFav){ btn.classList.remove('active'); void btn.offsetWidth; btn.classList.add('active'); }
}
setPageFavLabel(nowFav);
if(typeof SSTToast === 'function') SSTToast(nowFav ? '❤️ Added to favorites' : 'Removed from favorites');
}
document.addEventListener('DOMContentLoaded', function(){
if(typeof SSTFavorites === 'undefined') return;
const btn = document.getElementById('pageFavHeart');
const isFav = SSTFavorites.isFav(CERT_FAV_URL);
if(btn && isFav) btn.classList.add('active');
setPageFavLabel(isFav);
});// ── dark mode ────────────────────────────────────────────────
const DARK_MODE_KEY = 'sst_certgen_dark_mode';
const SUN_ICON = '';
const MOON_ICON = '';
function applyDarkMode(isDark){
document.body.classList.toggle('dark-mode', isDark);
const icon = document.getElementById('themeToggleIcon');
if(icon) icon.innerHTML = isDark ? MOON_ICON : SUN_ICON;
const dayLabel = document.getElementById('themeToggleDay');
const nightLabel = document.getElementById('themeToggleNight');
if(dayLabel) dayLabel.classList.toggle('active', !isDark);
if(nightLabel) nightLabel.classList.toggle('active', isDark);
}
function toggleDarkMode(){
const isDark = !document.body.classList.contains('dark-mode');
applyDarkMode(isDark);
try{ localStorage.setItem(DARK_MODE_KEY, isDark ? 'on' : 'off'); }catch(e){}
}
(function initDarkMode(){
let saved = null;
try{ saved = localStorage.getItem(DARK_MODE_KEY); }catch(e){}
const isDark = saved === 'on'; // always defaults to light unless the user has explicitly turned dark mode on themselves
applyDarkMode(isDark);
})();renderShareMenu();
renderAllUI();
initDrag();
pushHistory();
let _resizeT = null;
window.addEventListener('resize', () => { clearTimeout(_resizeT); _resizeT = setTimeout(renderCert, 150); });