Files
certificate-system/certificate-template-lab/app.js

489 lines
15 KiB
JavaScript

(function () {
const canvas = document.getElementById("certificateCanvas");
const ctx = canvas.getContext("2d");
const state = document.getElementById("renderState");
const qrScratch = document.getElementById("qrScratch");
const W = 1024;
const H = 759;
let scaleX = 1;
let scaleY = 1;
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.png";
original.onload = () => {
syncCanvasSize(true);
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;
const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth;
syncCanvasSize(usingOriginal);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
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 = "渲染异常";
}
}
function syncCanvasSize(usingOriginal) {
const nextWidth = usingOriginal ? original.naturalWidth : W;
const nextHeight = usingOriginal ? original.naturalHeight : H;
if (canvas.width !== nextWidth) canvas.width = nextWidth;
if (canvas.height !== nextHeight) canvas.height = nextHeight;
scaleX = nextWidth / W;
scaleY = nextHeight / H;
}
const areas = [
{ x: 150, y: 455, w: 760, h: 119, pad: 16 }
];
function eraseEditableAreas() {
areas.forEach((area) => eraseWithPaper(area));
}
function eraseWithPaper(area) {
if (!original.complete || !original.naturalWidth) return;
const pad = area.pad || 8;
const sourceX = Math.round((area.sourceX || 72) * scaleX);
const sourceY = Math.round((area.sourceY || Math.max(0, area.y - pad)) * scaleY);
const sourceW = Math.round(88 * scaleX);
const patch = document.createElement("canvas");
patch.width = Math.round((area.w + pad * 2) * scaleX);
patch.height = Math.round((area.h + pad * 2) * scaleY);
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,
sourceY,
tileW,
patch.height,
x,
0,
tileW,
patch.height
);
}
p.globalCompositeOperation = "destination-in";
p.globalCompositeOperation = "source-over";
p.globalAlpha = area.blendAlpha || 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.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(patch, Math.round((area.x - pad) * scaleX), Math.round((area.y - pad) * scaleY));
ctx.restore();
}
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, 400, 8);
x = drawInlineText(text.year, x, y, 24, 400, 12);
x = drawInlineText(value("startMonth"), x, y, 24, 400, 6);
x = drawInlineText(text.month, x, y, 24, 400, 12);
x = drawInlineText(value("startDay"), x, y, 24, 400, 4);
x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12);
x = drawInlineText(value("endYear"), x, y, 24, 400, 8);
x = drawInlineText(text.year, x, y, 24, 400, 12);
x = drawInlineText(value("endMonth"), x, y, 24, 400, 6);
x = drawInlineText(text.month, x, y, 24, 400, 12);
x = drawInlineText(value("endDay"), x, y, 24, 400, 4);
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 = "#111";
ctx.font = '700 13px "SimSun", serif';
const parts = (value("issueDate").match(/\d+/g) || []);
ctx.fillText(parts[0] || "", 500, 688);
ctx.fillText(parts[1] || "", 535, 688);
ctx.fillText(parts[2] || "", 566, 688);
}
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, "_");
}
})();