Initial certificate system

This commit is contained in:
nelso
2026-06-06 19:32:24 +08:00
commit ce4ed6213b
105 changed files with 9133 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# 结业证书模板调试模块
这个目录是证书模板的独立验证模块,暂时不接主系统数据库。
## 启动
在项目根目录执行:
```powershell
$py = "C:\Users\nelso\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
& $py -m http.server 8020 -d .\certificate-template-lab
```
然后访问:
```text
http://127.0.0.1:8020
```
## 当前用途
-`assets/original.jpg` 作为证书底图。
- 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。
- 支持下载 PNG用于先确认视觉效果。
- 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。

View 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, "_");
}
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,117 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>&#32467;&#19994;&#35777;&#20070;&#27169;&#26495;&#35843;&#35797;</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<main class="page">
<aside class="panel">
<div class="panel-head">
<p class="eyebrow">Certificate Lab</p>
<h1>&#32467;&#19994;&#35777;&#20070;&#27169;&#26495;&#35843;&#35797;</h1>
<p class="hint">&#36825;&#37324;&#20808;&#21333;&#29420;&#35843;&#35777;&#20070;&#35270;&#35273;&#65292;&#19981;&#25509;&#20027;&#31995;&#32479;&#25968;&#25454;&#12290;&#25913;&#23383;&#27573;&#21518;&#21491;&#20391;&#20250;&#23454;&#26102;&#37325;&#32472;&#12290;</p>
</div>
<section class="group">
<h2>&#23398;&#21592;&#19982;&#35838;&#31243;</h2>
<label>&#23398;&#21592;&#22995;&#21517;
<input id="studentName" value="&#24352;&#19977;">
</label>
<label>&#35838;&#31243;&#21517;&#31216;
<input id="courseName" value="&#26234;&#24935;&#36171;&#33021;&#30103;&#24840;&#24072;">
</label>
<label>&#35838;&#31243;&#38454;&#27573;
<input id="stageName" value="&#21021;&#32423;&#35838;&#31243;&#30340;&#19987;&#19994;&#23398;&#20064;&#12290;">
</label>
<label>&#35777;&#20070;&#32534;&#21495;
<input id="certificateNo" value="HY20260602DYC001">
</label>
</section>
<section class="group">
<h2>&#26085;&#26399;</h2>
<div class="grid">
<label>&#24320;&#22987;&#24180;
<input id="startYear" value="2026">
</label>
<label>&#24320;&#22987;&#26376;
<input id="startMonth" value="6">
</label>
<label>&#24320;&#22987;&#26085;
<input id="startDay" value="1">
</label>
<label>&#32467;&#26463;&#24180;
<input id="endYear" value="2026">
</label>
<label>&#32467;&#26463;&#26376;
<input id="endMonth" value="6">
</label>
<label>&#32467;&#26463;&#26085;
<input id="endDay" value="2">
</label>
</div>
<label>&#21457;&#35777;&#26085;&#26399;
<input id="issueDate" value="2026 &#24180; 6 &#26376; 2 &#26085;">
</label>
</section>
<section class="group muted-group">
<h2>&#24213;&#37096;&#22270;&#23618;</h2>
<p class="group-note">&#23548;&#24072;&#12289;&#26426;&#26500;&#21644;&#20108;&#32500;&#30721;&#20808;&#20351;&#29992;&#21407;&#22270;&#12290;&#23548;&#24072;&#20250;&#20316;&#20026;&#26368;&#19978;&#23618;&#22270;&#23618;&#35206;&#30422;&#12290;</p>
<label>&#23548;&#24072;&#31216;&#35859;
<input id="mentorLabel" value="&#23548;&#24072;&#65306;" disabled>
</label>
<label>&#23548;&#24072;&#22995;&#21517;
<input id="mentorName" value="&#21346;&#24935;" disabled>
</label>
<label>&#24038;&#20391;&#21333;&#20301;
<input id="issuerLeft" value="&#28145;&#22323;&#24066;&#24935;&#24840;&#25991;&#21270;&#31185;&#25216;&#26377;&#38480;&#20844;&#21496;" disabled>
</label>
<label>&#21491;&#20391;&#21333;&#20301;
<input id="issuerRight" value="&#22269;&#38469;&#29983;&#21629;&#26234;&#24935;&#21551;&#33945;&#30740;&#31350;&#38498;" disabled>
</label>
<label>&#26597;&#35810;&#38142;&#25509;
<input id="verifyUrl" value="https://example.com/cert/HY20260602DYC001" disabled>
</label>
</section>
<section class="group">
<h2>&#26174;&#31034;&#24320;&#20851;</h2>
<label class="check">
<input type="checkbox" id="showOriginal" checked>
&#20351;&#29992;&#21407;&#22270;&#20316;&#20026;&#24213;&#22270;
</label>
<label class="check">
<input type="checkbox" id="showDebug">
&#26174;&#31034;&#25830;&#38500;&#21306;&#22495;
</label>
</section>
<div class="actions">
<button id="resetBtn" type="button">&#24674;&#22797;&#31034;&#20363;</button>
<button id="downloadBtn" type="button">&#19979;&#36733; PNG</button>
</div>
</aside>
<section class="preview">
<div class="preview-head">
<div>
<p class="eyebrow">1024 x 759</p>
<h2>&#23454;&#26102;&#39044;&#35272;</h2>
</div>
<span id="renderState">&#21152;&#36733;&#20013;</span>
</div>
<div class="canvas-shell">
<canvas id="certificateCanvas" width="1024" height="759"></canvas>
</div>
</section>
</main>
<div id="qrScratch" aria-hidden="true"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="./app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,246 @@
:root {
color-scheme: light;
--bg: #f5f8fb;
--panel: #ffffff;
--line: #d9e2ec;
--text: #172033;
--muted: #657184;
--blue: #2f6fbd;
--blue-dark: #215493;
--gold: #a98732;
--shadow: 0 18px 45px rgba(30, 52, 82, .12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
color: var(--text);
background:
linear-gradient(120deg, rgba(47, 111, 189, .08), transparent 38%),
var(--bg);
}
.page {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 22px;
min-height: 100vh;
padding: 22px;
}
.panel,
.preview {
background: rgba(255, 255, 255, .92);
border: 1px solid rgba(217, 226, 236, .9);
box-shadow: var(--shadow);
}
.panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
}
.panel-head {
padding: 22px 22px 16px;
border-bottom: 1px solid var(--line);
}
.eyebrow {
margin: 0 0 6px;
color: var(--gold);
font-size: 12px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 22px;
}
h2 {
font-size: 15px;
}
.hint {
margin: 8px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.group {
padding: 16px 22px;
border-bottom: 1px solid var(--line);
}
.group h2 {
margin-bottom: 12px;
}
.group-note {
margin: -4px 0 12px;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.muted-group input:disabled {
color: #8b96a8;
background: #f2f5f8;
cursor: not-allowed;
}
label {
display: grid;
gap: 6px;
margin-bottom: 12px;
color: #3a4658;
font-size: 13px;
font-weight: 600;
}
input {
width: 100%;
border: 1px solid #cdd8e6;
border-radius: 6px;
padding: 10px 11px;
color: var(--text);
font: inherit;
font-size: 14px;
outline: none;
background: #fff;
}
input:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(47, 111, 189, .14);
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.check {
grid-template-columns: auto 1fr;
align-items: center;
gap: 9px;
margin-bottom: 8px;
font-weight: 500;
}
.check input {
width: 16px;
height: 16px;
margin: 0;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 16px 22px 22px;
margin-top: auto;
}
button {
border: 0;
border-radius: 6px;
padding: 11px 12px;
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 700;
}
#resetBtn {
color: var(--blue-dark);
background: #e8f1fc;
}
#downloadBtn {
color: #fff;
background: var(--blue);
}
.preview {
display: flex;
flex-direction: column;
min-width: 0;
border-radius: 8px;
overflow: hidden;
}
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--line);
}
#renderState {
color: var(--blue-dark);
background: #e8f1fc;
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
font-weight: 700;
}
.canvas-shell {
display: grid;
place-items: center;
flex: 1;
padding: 28px;
overflow: auto;
background:
linear-gradient(45deg, #eef3f8 25%, transparent 25%),
linear-gradient(-45deg, #eef3f8 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #eef3f8 75%),
linear-gradient(-45deg, transparent 75%, #eef3f8 75%);
background-size: 24px 24px;
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
}
canvas {
display: block;
width: min(100%, 1024px);
height: auto;
background: #faf7ef;
box-shadow: 0 18px 40px rgba(28, 42, 62, .18);
}
#qrScratch {
position: fixed;
left: -9999px;
top: -9999px;
width: 96px;
height: 96px;
overflow: hidden;
}
@media (max-width: 1000px) {
.page {
grid-template-columns: 1fr;
}
.panel {
max-height: none;
}
}