Initial certificate system
This commit is contained in:
465
certificate-template-lab/app.js
Normal file
465
certificate-template-lab/app.js
Normal file
@@ -0,0 +1,465 @@
|
||||
(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, "_");
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user