(function () { const canvas = document.getElementById("certificateCanvas"); const ctx = canvas.getContext("2d"); const state = document.getElementById("renderState"); const qrScratch = document.getElementById("qrScratch"); const W = canvas.width; const H = canvas.height; const text = { loading: "\u52a0\u8f7d\u4e2d", rendering: "\u6e32\u67d3\u4e2d", ready: "\u5df2\u66f4\u65b0", imageFailed: "\u539f\u56fe\u52a0\u8f7d\u5931\u8d25", title: "\u7ed3\u4e1a\u8bc1\u4e66", proves: "\u7279\u6b64\u8bc1\u660e", inWord: "\u5728", year: "\u5e74", month: "\u6708", day: "\u65e5", toWord: "\u81f3", finished: "\u65e5\u5b8c\u6210\u4e86", issueDate: "\u53d1\u8bc1\u65e5\u671f\uff1a", certNo: "\u8bc1\u4e66\u7f16\u53f7\uff1a", fallbackStudent: "\u5b66\u5458", filePrefix: "\u7ed3\u4e1a\u8bc1\u4e66" }; const defaults = { studentName: "\u5f20\u4e09", courseName: "\u667a\u6167\u8d4b\u80fd\u7597\u6108\u5e08", stageName: "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002", certificateNo: "HY20260602DYC001", startYear: "2026", startMonth: "6", startDay: "1", endYear: "2026", endMonth: "6", endDay: "2", issueDate: "2026 \u5e74 6 \u6708 2 \u65e5", mentorLabel: "\u5bfc\u5e08\uff1a", mentorName: "\u5362\u6167", issuerLeft: "\u6df1\u5733\u5e02\u6167\u6108\u6587\u5316\u79d1\u6280\u6709\u9650\u516c\u53f8", issuerRight: "\u56fd\u9645\u751f\u547d\u667a\u6167\u542f\u8499\u7814\u7a76\u9662", verifyUrl: "https://example.com/cert/HY20260602DYC001" }; const controls = {}; Object.keys(defaults).forEach((key) => { controls[key] = document.getElementById(key); }); controls.showOriginal = document.getElementById("showOriginal"); controls.showDebug = document.getElementById("showDebug"); const original = new Image(); original.src = "./assets/original.jpg"; original.onload = render; original.onerror = () => { state.textContent = text.imageFailed; render(); }; let renderTimer = null; let qrText = ""; bindEvents(); render(); function bindEvents() { Object.values(controls).forEach((input) => { if (!input) return; input.addEventListener("input", scheduleRender); input.addEventListener("change", scheduleRender); }); document.getElementById("resetBtn").addEventListener("click", () => { Object.entries(defaults).forEach(([key, value]) => { controls[key].value = value; }); controls.showOriginal.checked = true; controls.showDebug.checked = false; render(); }); document.getElementById("downloadBtn").addEventListener("click", () => { render(); const link = document.createElement("a"); const name = safeFileName(value("studentName") || text.fallbackStudent); link.download = `${text.filePrefix}_${name}.png`; link.href = canvas.toDataURL("image/png", 1); link.click(); }); } function scheduleRender() { window.clearTimeout(renderTimer); renderTimer = window.setTimeout(render, 80); } function value(key) { return (controls[key] && controls[key].value || "").trim(); } function render() { try { state.textContent = text.rendering; ctx.clearRect(0, 0, W, H); const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth; if (usingOriginal) { ctx.drawImage(original, 0, 0, W, H); eraseEditableAreas(); } else { drawFallbackBase(); } drawStudentName(); drawMainText(); drawIssueDate(); drawCertificateNo(); if (usingOriginal) { drawOriginalMentorLayer(); } else { drawMentor(); drawFooter(); drawQr(); } if (controls.showDebug.checked) { drawDebugAreas(); } state.textContent = text.ready; } catch (err) { console.error("Certificate render failed:", err); state.textContent = "渲染异常"; } } const areas = [ { x: 150, y: 442, w: 760, h: 132, pad: 16 }, { x: 404, y: 675, w: 220, h: 38, pad: 10 } ]; function eraseEditableAreas() { areas.forEach((area) => eraseWithPaper(area)); } function eraseWithPaper(area) { if (!original.complete || !original.naturalWidth) return; const pad = area.pad || 8; const sourceX = 72; const sourceW = 88; const patch = document.createElement("canvas"); patch.width = area.w + pad * 2; patch.height = area.h + pad * 2; const p = patch.getContext("2d"); for (let x = 0; x < patch.width; x += sourceW) { const tileW = Math.min(sourceW, patch.width - x); p.drawImage( original, sourceX, Math.max(0, area.y - pad), tileW, patch.height, x, 0, tileW, patch.height ); } p.globalCompositeOperation = "destination-in"; p.globalCompositeOperation = "source-over"; p.globalAlpha = 0.38; p.fillStyle = "#fbf7ef"; p.fillRect(0, 0, patch.width, patch.height); p.globalAlpha = 1; p.globalCompositeOperation = "destination-in"; const g = p.createLinearGradient(0, 0, 0, patch.height); g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(0.04, "rgba(0,0,0,1)"); g.addColorStop(0.96, "rgba(0,0,0,1)"); g.addColorStop(1, "rgba(0,0,0,0)"); p.fillStyle = g; p.fillRect(0, 0, patch.width, patch.height); p.globalCompositeOperation = "destination-in"; const gx = p.createLinearGradient(0, 0, patch.width, 0); gx.addColorStop(0, "rgba(0,0,0,0)"); gx.addColorStop(0.035, "rgba(0,0,0,1)"); gx.addColorStop(0.965, "rgba(0,0,0,1)"); gx.addColorStop(1, "rgba(0,0,0,0)"); p.fillStyle = gx; p.fillRect(0, 0, patch.width, patch.height); ctx.drawImage(patch, area.x - pad, area.y - pad); } function drawFallbackBase() { ctx.fillStyle = "#fbf7ef"; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = "#25344f"; ctx.lineWidth = 12; ctx.strokeRect(6, 6, W - 12, H - 12); ctx.strokeStyle = "#a98732"; ctx.lineWidth = 2; ctx.strokeRect(42, 38, W - 84, H - 76); ctx.textAlign = "center"; ctx.fillStyle = "#111"; ctx.font = '700 52px "SimSun", serif'; ctx.fillText(text.title, W / 2, 196); } function drawSubTitle() { ctx.textAlign = "center"; ctx.fillStyle = "#49341f"; ctx.font = '700 17px "SimSun", "Microsoft YaHei", serif'; ctx.fillText(text.proves, W / 2, 329); ctx.fillStyle = "#555"; ctx.font = '13px Georgia, serif'; ctx.fillText("This Certifies That", W / 2, 351); } function drawStudentName() { const name = value("studentName"); if (!name) return; ctx.textAlign = "center"; ctx.fillStyle = "#111"; ctx.font = '700 32px "KaiTi", "STKaiti", "SimSun", serif'; ctx.fillText(name, W / 2, 414); } function drawMainText() { const line2 = value("stageName"); ctx.fillStyle = "#111"; ctx.textAlign = "left"; let x = 185; const y = 494; x = drawInlineText(text.inWord, x, y, 24, 400, 18); x = drawInlineText(value("startYear"), x, y, 24, 900, 8, "dateNumber"); x = drawInlineText(text.year, x, y, 24, 400, 12); x = drawInlineText(value("startMonth"), x, y, 24, 900, 6, "dateNumber"); x = drawInlineText(text.month, x, y, 24, 400, 12); x = drawInlineText(value("startDay"), x, y, 24, 900, 4, "dateNumber"); x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12); x = drawInlineText(value("endYear"), x, y, 24, 900, 8, "dateNumber"); x = drawInlineText(text.year, x, y, 24, 400, 12); x = drawInlineText(value("endMonth"), x, y, 24, 900, 6, "dateNumber"); x = drawInlineText(text.month, x, y, 24, 400, 12); x = drawInlineText(value("endDay"), x, y, 24, 900, 4, "dateNumber"); x = drawInlineText(`${text.day}\u5b8c\u6210\u4e86`, x, y, 24, 400, 8); drawInlineFlow([ { text: `\u201c${value("courseName")}\u201d`, style: "course" }, { text: line2, style: "normal", gapBefore: 14 }, ], x, y); } function drawInlineFlow(segments, startX, startY) { let x = startX; let y = startY; const lineStart = 185; const lineHeight = 42; segments.forEach((segment) => { if (segment.gapBefore) x += segment.gapBefore; Array.from(segment.text || "").forEach((char) => { const style = inlineStyle(segment.style); ctx.font = style.font; const maxRight = y === startY ? 900 : 730; const width = ctx.measureText(char).width; if (x + width > maxRight && x > lineStart) { x = lineStart; y += lineHeight; } ctx.fillText(char, x, y); if (segment.style === "course") ctx.fillText(char, x + 0.45, y); x += width; }); }); } function inlineStyle(style) { if (style === "course") { return { font: '900 27px "KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", serif' }; } return { font: '400 24px "SimSun", "Noto Serif SC", serif' }; } function drawInlineText(raw, x, y, size, weight, gap, style) { const textValue = raw || ""; const useKaiti = style === true || style === "dateNumber"; const family = useKaiti ? '"KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", serif' : '"SimSun", "Noto Serif SC", serif'; ctx.font = `${weight} ${size}px ${family}`; ctx.fillText(textValue, x, y); if (style === true || style === "dateNumber") { ctx.fillText(textValue, x + 0.45, y); } return x + ctx.measureText(textValue).width + gap; } function drawMentor() { const label = value("mentorLabel"); const name = value("mentorName"); if (!label && !name) return; ctx.fillStyle = "#111"; ctx.textAlign = "left"; ctx.font = '700 21px "SimSun", "Microsoft YaHei", serif'; ctx.fillText(label, 740, 586); ctx.save(); ctx.translate(812, 594); ctx.rotate(-0.05); ctx.fillStyle = "#1b1b1b"; ctx.font = '700 34px "KaiTi", "STKaiti", "SimSun", serif'; ctx.fillText(name, 0, 0); ctx.restore(); } function drawOriginalMentorLayer() { if (!original.complete || !original.naturalWidth) return; ctx.drawImage(original, 720, 535, 230, 94, 720, 535, 230, 94); } function drawFooter() { ctx.fillStyle = "#111"; ctx.font = '700 20px "SimSun", "Microsoft YaHei", serif'; ctx.textAlign = "left"; drawTextFit(value("issuerLeft"), 190, 643, 300, 20); ctx.textAlign = "right"; drawTextFit(value("issuerRight"), 835, 643, 320, 20); ctx.textAlign = "center"; drawIssueDate(); } function drawIssueDate() { ctx.textAlign = "center"; ctx.fillStyle = "#43382f"; ctx.font = '700 13px "SimSun", serif'; ctx.fillText(`${text.issueDate}${value("issueDate")}`, W / 2, 704); } function drawCertificateNo() { const no = value("certificateNo"); if (!no) return; ctx.textAlign = "left"; ctx.fillStyle = "#111827"; ctx.font = '700 14px Arial, "Microsoft YaHei", sans-serif'; ctx.fillText(`${text.certNo}${no}`, 105, 76); } function drawQr() { const x = W / 2 - 33; const y = 602; const box = 66; const qrNode = getQrNode(box); ctx.save(); ctx.fillStyle = "#fff"; ctx.fillRect(x - 5, y - 5, box + 10, box + 10); ctx.strokeStyle = "#d3c6a4"; ctx.strokeRect(x - 5, y - 5, box + 10, box + 10); if (qrNode) { try { ctx.drawImage(qrNode, x, y, box, box); } catch (err) { console.warn("QR image is not ready yet, using fallback.", err); drawQrFallbackPixels(x, y); window.setTimeout(render, 120); } } else { drawQrFallbackPixels(x, y); } ctx.fillStyle = "#1f67ad"; ctx.fillRect(x + 28, y + 28, 10, 10); ctx.restore(); } function getQrNode(size) { if (!window.QRCode || !qrScratch) return null; const nextText = value("verifyUrl") || "https://example.com"; if (nextText !== qrText || !qrScratch.firstChild) { qrText = nextText; qrScratch.innerHTML = ""; new QRCode(qrScratch, { text: nextText, width: size, height: size, colorDark: "#111111", colorLight: "#ffffff", correctLevel: QRCode.CorrectLevel.H }); } const canvasNode = qrScratch.querySelector("canvas"); if (canvasNode) return canvasNode; const imgNode = qrScratch.querySelector("img"); if (imgNode && imgNode.complete && imgNode.naturalWidth) return imgNode; return null; } function drawQrFallbackPixels(x, y) { ctx.fillStyle = "#111"; const seed = hash(value("verifyUrl")); const cell = 3; for (let row = 0; row < 22; row++) { for (let col = 0; col < 22; col++) { const fixed = finder(col, row) || finder(col - 15, row) || finder(col, row - 15); const on = fixed || ((seed + row * 17 + col * 31 + row * col) % 7 < 3); if (on) ctx.fillRect(x + col * cell, y + row * cell, cell, cell); } } } function finder(col, row) { if (col < 0 || row < 0 || col > 6 || row > 6) return false; return col === 0 || row === 0 || col === 6 || row === 6 || (col >= 2 && col <= 4 && row >= 2 && row <= 4); } function hash(raw) { let n = 0; for (let i = 0; i < raw.length; i++) { n = (n * 31 + raw.charCodeAt(i)) >>> 0; } return n; } function drawTextFit(raw, x, y, maxWidth, size) { let fontSize = size; const textValue = raw || ""; do { ctx.font = ctx.font.replace(/\d+px/, `${fontSize}px`); fontSize--; } while (ctx.measureText(textValue).width > maxWidth && fontSize > 12); ctx.fillText(textValue, x, y); } function drawDebugAreas() { ctx.save(); ctx.strokeStyle = "#f04438"; ctx.lineWidth = 2; ctx.setLineDash([6, 5]); areas.forEach((area) => ctx.strokeRect(area.x, area.y, area.w, area.h)); ctx.restore(); } function safeFileName(raw) { return raw.replace(/[\\/:*?"<>|]/g, "_"); } })();