489 lines
15 KiB
JavaScript
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, "_");
|
|
}
|
|
})();
|