Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Token Sheet Generator</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <!-- Google Fonts --> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Roboto:wght@400;700&family=Roboto+Condensed:wght@400;700&family=Noto+Sans:wght@400;700&family=PT+Sans+Narrow:wght@400;700&family=Source+Code+Pro:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <!-- Icons --> | |
| <link href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css" rel="stylesheet"> | |
| <!-- jsPDF for optional PDF export --> | |
| <script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script> | |
| <style> | |
| :root{ | |
| --bg: #0b0d10; | |
| --panel: #12161b; | |
| --panel-2: #0e1318; | |
| --muted: #a8b3c5; | |
| --text: #e8edf5; | |
| --brand: #76b7ff; | |
| --accent: #74e0c0; | |
| --danger: #ff7a7a; | |
| --ring: 0 0 0 2px color-mix(in hsl, var(--brand) 40%, transparent); | |
| --radius: 14px; | |
| --gap: clamp(12px, 1.5vw, 18px); | |
| --shadow-1: 0 2px 20px rgba(0,0,0,.35), 0 10px 40px rgba(0,0,0,.25); | |
| --shadow-2: 0 1px 0 rgba(255,255,255,.05) inset, 0 0 0 1px rgba(255,255,255,.04) inset; | |
| --input-bg: color-mix(in hsl, var(--panel) 85%, #000 15%); | |
| --grid: 12px; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{height:100%} | |
| body{ | |
| margin:0; | |
| background: radial-gradient(1200px 800px at 10% -10%, #12171e 0%, #0b0d10 40%, #0b0d10 100%), var(--bg); | |
| color: var(--text); | |
| font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, "Noto Sans", "PT Sans Narrow", "Source Code Pro", Arial, sans-serif; | |
| line-height: 1.35; | |
| } | |
| header{ | |
| position: sticky; | |
| top:0; | |
| background: color-mix(in hsl, var(--panel) 86%, #000 14%); | |
| backdrop-filter: saturate(1.2) blur(10px); | |
| border-bottom: 1px solid rgba(255,255,255,.06); | |
| z-index:10; | |
| } | |
| .header-inner{ | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 16px clamp(14px, 2.2vw, 24px); | |
| display:flex; | |
| align-items:center; | |
| gap:14px; | |
| } | |
| .brand{ | |
| display:flex; | |
| align-items:center; | |
| gap:10px; | |
| font-weight:700; | |
| letter-spacing:.2px; | |
| } | |
| .brand .logo{ | |
| width:32px;height:32px;border-radius:10px; | |
| background: conic-gradient(from 210deg, var(--brand), var(--accent), #b09cff, var(--brand)); | |
| box-shadow: inset 0 0 18px rgba(255,255,255,.25), 0 6px 18px rgba(0,0,0,.5); | |
| position: relative; | |
| } | |
| .brand .logo::after{ | |
| content:""; | |
| position:absolute; inset: 3px 3px auto auto; | |
| width:9px;height:9px;border-radius:50%; | |
| background: #fff3; filter: blur(1px); | |
| } | |
| .brand-title{ | |
| font-size: clamp(16px, 2.2vw, 20px); | |
| } | |
| .brand small{ | |
| color: var(--muted); font-weight:600; opacity:.8; | |
| } | |
| .spacer{flex:1} | |
| .link-anycoder{ | |
| color: var(--accent); | |
| text-decoration: none; | |
| font-weight: 600; | |
| display:inline-flex; align-items:center; gap:8px; | |
| padding:8px 12px; border-radius: 999px; | |
| background: linear-gradient(180deg, rgba(116,224,192,.16), rgba(116,224,192,.08)); | |
| border: 1px solid rgba(116,224,192,.35); | |
| } | |
| .link-anycoder:hover{ | |
| filter: brightness(1.05); | |
| box-shadow: 0 0 0 3px rgba(116,224,192,.15); | |
| } | |
| main{ | |
| max-width: 1200px; | |
| margin: 18px auto 40px; | |
| padding: 0 clamp(14px, 2.2vw, 24px); | |
| display:grid; | |
| grid-template-columns: minmax(280px, 420px) 1fr; | |
| gap: var(--gap); | |
| } | |
| .panel{ | |
| background: linear-gradient(180deg, color-mix(in hsl, var(--panel) 92%, #000 8%), color-mix(in hsl, var(--panel-2) 92%, #000 8%)); | |
| border-radius: var(--radius); | |
| box-shadow: var(--shadow-1), var(--shadow-2); | |
| border: 1px solid rgba(255,255,255,.06); | |
| overflow: clip; | |
| } | |
| .form{ | |
| padding: clamp(14px, 2.4vw, 20px); | |
| display: grid; | |
| gap: 14px; | |
| } | |
| .section{ | |
| border: 1px dashed rgba(255,255,255,.08); | |
| border-radius: 12px; | |
| padding: 12px; | |
| display: grid; | |
| gap: 10px; | |
| background: | |
| linear-gradient(180deg, rgba(255,255,255,.03), transparent 40%), | |
| repeating-linear-gradient(90deg, transparent 0 var(--grid), rgba(255,255,255,.02) var(--grid) calc(var(--grid)*2)); | |
| } | |
| .section h3{ | |
| margin: 2px 0 6px; | |
| font-size: 14px; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: .12em; | |
| font-weight: 700; | |
| display:flex; align-items:center; gap:8px; | |
| } | |
| .row{ | |
| display:grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 10px; | |
| } | |
| .row-3{ | |
| display:grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 10px; | |
| } | |
| .field{ | |
| display:grid; gap:6px; | |
| } | |
| .field label{ | |
| font-size: 12px; color: var(--muted); | |
| display:flex; align-items:center; gap:8px; | |
| } | |
| .hint{ | |
| font-size: 11px; color: var(--muted); opacity:.85; | |
| } | |
| textarea, select, input[type="number"], input[type="text"]{ | |
| width: 100%; | |
| padding: 10px 12px; | |
| background: var(--input-bg); | |
| border: 1px solid rgba(255,255,255,.09); | |
| color: var(--text); | |
| border-radius: 10px; | |
| outline: none; | |
| transition: .15s border, .15s box-shadow, .15s background; | |
| font-size: 14px; | |
| } | |
| textarea{ | |
| min-height: 140px; resize: vertical; | |
| font-family: ui-monospace, "Source Code Pro", "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; | |
| } | |
| select:hover, input:hover, textarea:hover{ | |
| border-color: rgba(255,255,255,.16); | |
| } | |
| select:focus, input:focus, textarea:focus{ | |
| border-color: var(--brand); | |
| box-shadow: var(--ring); | |
| } | |
| .preset-buttons{ | |
| display:flex; flex-wrap: wrap; gap:8px; | |
| } | |
| .chip{ | |
| border: 1px solid rgba(255,255,255,.14); | |
| background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02)); | |
| color: var(--text); | |
| border-radius: 999px; | |
| padding: 8px 12px; | |
| font-size: 13px; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .chip:hover{ border-color: var(--brand); box-shadow: var(--ring); } | |
| .actions{ | |
| display:flex; flex-wrap:wrap; gap:10px; margin-top: 6px; | |
| align-items: center; | |
| } | |
| .btn{ | |
| appearance: none; | |
| border: 1px solid rgba(255,255,255,.14); | |
| color: var(--text); | |
| background: linear-gradient(180deg, rgba(118,183,255,.22), rgba(118,183,255,.10)); | |
| padding: 10px 14px; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| font-weight: 700; | |
| letter-spacing:.2px; | |
| display:inline-flex; align-items:center; gap:10px; | |
| transition: .15s transform, .15s box-shadow, .15s filter, .2s opacity; | |
| } | |
| .btn.secondary{ | |
| background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03)); | |
| } | |
| .btn.accent{ | |
| background: linear-gradient(180deg, rgba(116,224,192,.25), rgba(116,224,192,.12)); | |
| border-color: rgba(116,224,192,.45); | |
| } | |
| .btn.danger{ | |
| background: linear-gradient(180deg, rgba(255,122,122,.25), rgba(255,122,122,.12)); | |
| border-color: rgba(255,122,122,.45); | |
| } | |
| .btn[disabled]{ opacity:.5; cursor: not-allowed; } | |
| .btn:hover{ transform: translateY(-1px); filter: brightness(1.06); } | |
| .btn:active{ transform: translateY(0); } | |
| .inline{ | |
| display: inline-flex; gap:10px; align-items:center; flex-wrap: wrap; | |
| } | |
| .preview{ | |
| padding: clamp(10px, 2vw, 16px); | |
| display:grid; | |
| gap: 12px; | |
| } | |
| .preview-header{ | |
| display:flex; align-items:center; justify-content:space-between; gap:10px; | |
| padding: 10px 12px; | |
| background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02)); | |
| border: 1px solid rgba(255,255,255,.08); | |
| border-radius: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .stat{ | |
| color: var(--muted); | |
| font-size: 13px; | |
| } | |
| .canvas-wrap{ | |
| position: relative; | |
| width: 100%; | |
| aspect-ratio: 8.5 / 11; | |
| background: | |
| radial-gradient(800px 400px at 80% -20%, rgba(116,224,192,.08), transparent 40%), | |
| radial-gradient(700px 320px at 10% 110%, rgba(118,183,255,.10), transparent 40%), | |
| linear-gradient(180deg, #0d1117, #0a0d12); | |
| border-radius: 16px; | |
| border: 1px solid rgba(255,255,255,.08); | |
| box-shadow: var(--shadow-1); | |
| overflow: hidden; | |
| display:grid; | |
| place-items: center; | |
| padding: clamp(8px, 1.6vw, 16px); | |
| } | |
| canvas{ | |
| max-width: 100%; | |
| max-height: 100%; | |
| border-radius: 10px; | |
| background: #fff; | |
| image-rendering: -webkit-optimize-contrast; | |
| image-rendering: crisp-edges; | |
| } | |
| .footer-tip{ | |
| color: var(--muted); | |
| font-size: 12px; | |
| text-align: center; | |
| padding: 8px 10px 16px; | |
| opacity:.9; | |
| } | |
| .toggle{ | |
| display:inline-grid; grid-auto-flow: column; gap: 6px; align-items:center; | |
| background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03)); | |
| border: 1px solid rgba(255,255,255,.12); | |
| padding: 4px; border-radius: 999px; | |
| } | |
| .toggle button{ | |
| border: none; background: transparent; color: var(--muted); padding: 6px 10px; border-radius: 999px; cursor: pointer; font-weight:700; | |
| } | |
| .toggle button.active{ | |
| color: var(--text); | |
| background: linear-gradient(180deg, rgba(118,183,255,.22), rgba(118,183,255,.10)); | |
| } | |
| @media (max-width: 980px){ | |
| main{ grid-template-columns: 1fr; } | |
| .canvas-wrap{ aspect-ratio: 8.5 / 11; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="header-inner"> | |
| <div class="brand"> | |
| <div class="logo"></div> | |
| <div class="brand-title">Token Sheet Generator <small>browser-only</small></div> | |
| </div> | |
| <div class="spacer"></div> | |
| <a class="link-anycoder" href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener"> | |
| <i class="ph ph-code"></i> | |
| Built with anycoder | |
| </a> | |
| </div> | |
| </header> | |
| <main> | |
| <!-- Controls --> | |
| <section class="panel"> | |
| <form class="form" id="controlForm" onsubmit="return false;"> | |
| <div class="section"> | |
| <h3><i class="ph ph-text-aa"></i> Token Labels</h3> | |
| <div class="field"> | |
| <label for="labels">Enter one label per line</label> | |
| <textarea id="labels" placeholder="Goblin | |
| Orc Warrior | |
| Healing Potion | |
| +1 Sword | |
| Treasure Chest"></textarea> | |
| <div class="hint">Top half of each token is blank for art. The label stays strictly in the bottom half. Long labels will wrap and then shrink to fit.</div> | |
| </div> | |
| <div class="inline"> | |
| <button class="btn secondary" id="shuffleBtn" title="Shuffle labels"><i class="ph ph-shuffle"></i> Shuffle</button> | |
| <button class="btn secondary" id="sortBtn" title="Sort labels"><i class="ph ph-sort-ascending"></i> Sort A→Z</button> | |
| <button class="btn danger" id="clearBtn" title="Clear labels"><i class="ph ph-trash"></i> Clear</button> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <h3><i class="ph ph-squares-four"></i> Token Size</h3> | |
| <div class="preset-buttons" id="presetButtons"> | |
| <button class="chip" data-w="10" data-h="10" data-unit="mm">1 cm square (10×10 mm)</button> | |
| <button class="chip" data-w="10" data-h="18" data-unit="mm">Scrabble tile (10×18 mm)</button> | |
| <button class="chip" data-w="25.4" data-h="25.4" data-unit="in">1 inch token (25.4×25.4 mm)</button> | |
| <button class="chip" data-w="50" data-h="50" data-unit="mm">Large 50×50 mm</button> | |
| <button class="chip" data-w="2" data-h="2" data-unit="in">2 inch square</button> | |
| </div> | |
| <div class="row"> | |
| <div class="field"> | |
| <label for="tokenWidth">Custom width</label> | |
| <input type="number" id="tokenWidth" step="0.1" min="1" value="25.4"> | |
| </div> | |
| <div class="field"> | |
| <label for="tokenHeight">Custom height</label> | |
| <input type="number" id="tokenHeight" step="0.1" min="1" value="25.4"> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <div class="field"> | |
| <label for="unit">Units</label> | |
| <select id="unit"> | |
| <option value="mm">mm</option> | |
| <option value="in" selected>in</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="fontFamily">Font</label> | |
| <select id="fontFamily"> | |
| <option value="Inter">Inter</option> | |
| <option value="Roboto" selected>Roboto</option> | |
| <option value="Roboto Condensed">Roboto Condensed</option> | |
| <option value="Noto Sans">Noto Sans</option> | |
| <option value="PT Sans Narrow">PT Sans Narrow</option> | |
| <option value="Source Code Pro">Source Code Pro (monospace)</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <div class="field"> | |
| <label for="cornerRadius">Corner radius</label> | |
| <input type="number" id="cornerRadius" min="0" step="0.5" value="2"> | |
| <div class="hint">Rounded token corners in mm.</div> | |
| </div> | |
| <div class="field"> | |
| <label for="divider">Divider</label> | |
| <div class="toggle" id="dividerToggle" role="tablist" aria-label="Divider style"> | |
| <button type="button" data-div="line" class="active"><i class="ph ph-minus"></i> Line</button> | |
| <button type="button" data-div="none"><i class="ph ph-x"></i> None</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <h3><i class="ph ph-printer"></i> Page Settings</h3> | |
| <div class="row-3"> | |
| <div class="field"> | |
| <label for="pageSize">Page size</label> | |
| <select id="pageSize"> | |
| <option value="letter" selected>US Letter (8.5×11 in)</option> | |
| <option value="a4">A4 (210×297 mm)</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="dpi">DPI</label> | |
| <input type="number" id="dpi" min="72" max="1200" step="1" value="300"> | |
| </div> | |
| <div class="field"> | |
| <label for="margin">Margins</label> | |
| <input type="number" id="margin" min="0" step="0.1" value="10"> | |
| <div class="hint">Margin in mm (default 10 mm). Cut lines will be drawn on the outer margin edges.</div> | |
| </div> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn" id="renderBtn"><i class="ph ph-magic-wand"></i> Render Sheet</button> | |
| <button class="btn secondary" id="downloadPNGBtn" disabled><i class="ph ph-image"></i> Download PNG</button> | |
| <button class="btn accent" id="downloadPDFBtn" disabled><i class="ph ph-file-pdf"></i> Download PDF</button> | |
| <span class="inline" style="margin-left:auto"> | |
| <label class="hint" for="cutMarks" style="display:inline-flex;align-items:center;gap:8px"> | |
| <input id="cutMarks" type="checkbox" checked> | |
| Show cut marks | |
| </label> | |
| </span> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <h3><i class="ph ph-palette"></i> Style</h3> | |
| <div class="row"> | |
| <div class="field"> | |
| <label for="textColor">Text color</label> | |
| <input type="color" id="textColor" value="#000000"> | |
| </div> | |
| <div class="field"> | |
| <label for="borderColor">Border color</label> | |
| <input type="color" id="borderColor" value="#1c1f25"> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <div class="field"> | |
| <label for="fillTop">Top fill</label> | |
| <input type="color" id="fillTop" value="#ffffff"> | |
| </div> | |
| <div class="field"> | |
| <label for="fillBottom">Bottom fill</label> | |
| <input type="color" id="fillBottom" value="#ffffff"> | |
| </div> | |
| </div> | |
| <div class="hint">Use identical colors for a flat look, or slightly different tones for a subtle two-zone token.</div> | |
| </div> | |
| <div class="section"> | |
| <h3><i class="ph ph-info"></i> Tips</h3> | |
| <div class="hint"> | |
| - Use higher DPI (e.g., 300) for sharp print quality.<br> | |
| - Choose a narrow font for more characters per token, like Roboto Condensed or PT Sans Narrow. For fixed-width aesthetics, use Source Code Pro.<br> | |
| - Tokens are placed in a grid; only as many labels as you enter are drawn. | |
| </div> | |
| </div> | |
| </form> | |
| </section> | |
| <!-- Preview --> | |
| <section class="panel"> | |
| <div class="preview"> | |
| <div class="preview-header"> | |
| <div class="stat" id="statPage">Page: —</div> | |
| <div class="stat" id="statGrid">Grid: —</div> | |
| <div class="stat" id="statCount">Tokens drawn: —</div> | |
| </div> | |
| <div class="canvas-wrap" id="canvasWrap" aria-live="polite"> | |
| <canvas id="sheetCanvas"></canvas> | |
| </div> | |
| <div class="footer-tip">The on-screen preview shows your page at screen resolution; exports use full DPI for printing.</div> | |
| </div> | |
| </section> | |
| </main> | |
| <script> | |
| // Helpers to convert units to pixels at a given DPI. | |
| const INCH_TO_MM = 25.4; | |
| function mmToPx(mm, dpi){ | |
| return (mm / INCH_TO_MM) * dpi; | |
| } | |
| function inToPx(inches, dpi){ | |
| return inches * dpi; | |
| } | |
| // Page sizes in inches and mm. | |
| const PAGE_SIZES = { | |
| letter: { wIn: 8.5, hIn: 11, wMm: 215.9, hMm: 279.4, label: 'US Letter' }, | |
| a4: { wIn: 8.27, hIn: 11.69, wMm: 210, hMm: 297, label: 'A4' } | |
| }; | |
| // UI Elements | |
| const els = { | |
| labels: document.getElementById('labels'), | |
| presetButtons: document.getElementById('presetButtons'), | |
| tokenWidth: document.getElementById('tokenWidth'), | |
| tokenHeight: document.getElementById('tokenHeight'), | |
| unit: document.getElementById('unit'), | |
| fontFamily: document.getElementById('fontFamily'), | |
| pageSize: document.getElementById('pageSize'), | |
| dpi: document.getElementById('dpi'), | |
| margin: document.getElementById('margin'), | |
| renderBtn: document.getElementById('renderBtn'), | |
| downloadPNGBtn: document.getElementById('downloadPNGBtn'), | |
| downloadPDFBtn: document.getElementById('downloadPDFBtn'), | |
| canvas: document.getElementById('sheetCanvas'), | |
| canvasWrap: document.getElementById('canvasWrap'), | |
| statPage: document.getElementById('statPage'), | |
| statGrid: document.getElementById('statGrid'), | |
| statCount: document.getElementById('statCount'), | |
| cornerRadius: document.getElementById('cornerRadius'), | |
| dividerToggle: document.getElementById('dividerToggle'), | |
| cutMarks: document.getElementById('cutMarks'), | |
| textColor: document.getElementById('textColor'), | |
| borderColor: document.getElementById('borderColor'), | |
| fillTop: document.getElementById('fillTop'), | |
| fillBottom: document.getElementById('fillBottom'), | |
| shuffleBtn: document.getElementById('shuffleBtn'), | |
| sortBtn: document.getElementById('sortBtn'), | |
| clearBtn: document.getElementById('clearBtn'), | |
| }; | |
| let dividerStyle = 'line'; // 'line' | 'none' | |
| // Maintain current render state | |
| let lastRender = null; | |
| // Fit canvas element to correct pixel size and adjust preview ratio | |
| function sizeCanvasForPage(ctx, pageKey, dpi){ | |
| const page = PAGE_SIZES[pageKey]; | |
| const pxW = inToPx(page.wIn, dpi); | |
| const pxH = inToPx(page.hIn, dpi); | |
| els.canvas.width = Math.round(pxW); | |
| els.canvas.height = Math.round(pxH); | |
| // Update preview aspect to match page | |
| els.canvasWrap.style.aspectRatio = `${page.wIn} / ${page.hIn}`; | |
| // Draw white background | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(0, 0, els.canvas.width, els.canvas.height); | |
| } | |
| // Parse labels | |
| function getLabels(){ | |
| return els.labels.value | |
| .split(/\r?\n/) | |
| .map(s => s.trim()) | |
| .filter(Boolean); | |
| } | |
| // Convert token size to pixels | |
| function tokenSizeToPx(width, height, unit, dpi){ | |
| if(unit === 'mm'){ | |
| return { w: mmToPx(width, dpi), h: mmToPx(height, dpi) }; | |
| }else{ // inches | |
| return { w: inToPx(width, dpi), h: inToPx(height, dpi) }; | |
| } | |
| } | |
| // Rounded rect utility | |
| function roundRectPath(ctx, x, y, w, h, r){ | |
| const rr = Math.max(0, Math.min(r, Math.min(w, h)/2)); | |
| ctx.beginPath(); | |
| ctx.moveTo(x+rr, y); | |
| ctx.lineTo(x+w-rr, y); | |
| ctx.quadraticCurveTo(x+w, y, x+w, y+rr); | |
| ctx.lineTo(x+w, y+h-rr); | |
| ctx.quadraticCurveTo(x+w, y+h, x+w-rr, y+h); | |
| ctx.lineTo(x+rr, y+h); | |
| ctx.quadraticCurveTo(x, y+h, x, y+h-rr); | |
| ctx.lineTo(x, y+rr); | |
| ctx.quadraticCurveTo(x, y, x+rr, y); | |
| ctx.closePath(); | |
| } | |
| // Draw single token | |
| function drawToken(ctx, x, y, w, h, label, fontFamily, options){ | |
| const { textColor, borderColor, fillTop, fillBottom, divider, radiusPx } = options; | |
| // Fill halves with proper clipping to keep top/bottom separated | |
| // Full token clipping for border radii | |
| ctx.save(); | |
| roundRectPath(ctx, x, y, w, h, radiusPx); | |
| ctx.clip(); | |
| // Top half fill | |
| ctx.fillStyle = fillTop; | |
| ctx.fillRect(x, y, w, h/2); | |
| // Bottom half fill | |
| ctx.fillStyle = fillBottom; | |
| ctx.fillRect(x, y + h/2, w, h/2); | |
| // Divider between halves (drawn inside the clipped token) | |
| if(divider === 'line'){ | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y + h/2); | |
| ctx.lineTo(x + w, y + h/2); | |
| ctx.lineWidth = Math.max(1, Math.floor(Math.min(w,h)*0.01)); | |
| ctx.strokeStyle = '#888'; | |
| ctx.globalAlpha = 0.5; | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| // Border | |
| ctx.save(); | |
| roundRectPath(ctx, x, y, w, h, radiusPx); | |
| ctx.lineWidth = Math.max(1, Math.floor(Math.min(w, h) * 0.02)); | |
| ctx.strokeStyle = '#000000'; | |
| ctx.stroke(); | |
| ctx.lineWidth = Math.max(1, Math.floor(Math.min(w, h) * 0.012)); | |
| ctx.strokeStyle = borderColor; | |
| ctx.stroke(); | |
| ctx.restore(); | |
| // Strict bottom-half text region | |
| const pad = Math.max(4, Math.floor(Math.min(w, h) * 0.06)); | |
| const textBox = { | |
| x: x + pad, | |
| y: y + Math.ceil(h/2) + pad, // start strictly below midline | |
| w: w - pad*2, | |
| h: Math.floor(h/2 - pad*2) // confined to bottom half only | |
| }; | |
| // Base font size relative to token height | |
| const baseFontPx = Math.max(6, Math.floor(h * 0.18)); | |
| // Fit text (wrap + shrink) | |
| drawFittedText(ctx, label, textBox, baseFontPx, 6, fontFamily, textColor); | |
| } | |
| // Measure wrapping lines for a given font size | |
| function wrapLines(ctx, text, maxWidth){ | |
| const words = text.split(/\s+/); | |
| const lines = []; | |
| let current = ''; | |
| for(const word of words){ | |
| const tentative = current ? current + ' ' + word : word; | |
| const w = ctx.measureText(tentative).width; | |
| if(w <= maxWidth || current === ''){ | |
| current = tentative; | |
| }else{ | |
| lines.push(current); | |
| current = word; | |
| } | |
| } | |
| if(current) lines.push(current); | |
| return lines; | |
| } | |
| // Draw text centered inside box, shrinking font to fit height and width | |
| function drawFittedText(ctx, text, box, baseFont, minFont, fontFamily, color){ | |
| let fontPx = baseFont; | |
| let lines; | |
| while(fontPx >= minFont){ | |
| ctx.font = `${fontPx}px "${fontFamily}", system-ui, sans-serif`; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| lines = wrapLines(ctx, text, box.w); | |
| const lineHeight = Math.round(fontPx * 1.15); | |
| const totalHeight = lines.length * lineHeight; | |
| const tooWide = lines.some(line => ctx.measureText(line).width > box.w); | |
| if(totalHeight <= box.h && !tooWide){ | |
| break; | |
| } | |
| fontPx -= 1; | |
| } | |
| if(fontPx < minFont){ | |
| fontPx = minFont; | |
| ctx.font = `${fontPx}px "${fontFamily}", system-ui, sans-serif`; | |
| let txt = text; | |
| while(ctx.measureText(txt + '…').width > box.w && txt.length > 1){ | |
| txt = txt.slice(0, -1); | |
| } | |
| lines = [txt + (txt !== text ? '…' : '')]; | |
| } | |
| const lineHeight = Math.round(fontPx * 1.15); | |
| const totalHeight = lines.length * lineHeight; | |
| // Keep vertically centered within the bottom half only | |
| const startY = box.y + Math.max(0, (box.h - totalHeight)/2) + lineHeight/2; | |
| ctx.fillStyle = color || '#000'; | |
| lines.forEach((line, i) => { | |
| const lx = box.x + box.w/2; | |
| const ly = startY + i * lineHeight; | |
| ctx.fillText(line, lx, ly); | |
| }); | |
| } | |
| // Draw outside margin cut lines (crop marks) around the printable area | |
| function drawOuterCutLines(ctx, marginPx, pageW, pageH){ | |
| const len = Math.max(12, Math.round(Math.min(pageW, pageH) * 0.015)); // length of crop marks | |
| const w = pageW, h = pageH; | |
| const x1 = marginPx, x2 = w - marginPx; | |
| const y1 = marginPx, y2 = h - marginPx; | |
| ctx.save(); | |
| ctx.strokeStyle = '#0b0f18'; | |
| ctx.globalAlpha = 0.7; | |
| ctx.lineWidth = 1; | |
| // Corners - outside the content area | |
| // Top-left | |
| ctx.beginPath(); | |
| ctx.moveTo(x1 - len, y1); ctx.lineTo(x1, y1); | |
| ctx.moveTo(x1, y1 - len); ctx.lineTo(x1, y1); | |
| ctx.stroke(); | |
| // Top-right | |
| ctx.beginPath(); | |
| ctx.moveTo(x2 + len, y1); ctx.lineTo(x2, y1); | |
| ctx.moveTo(x2, y1 - len); ctx.lineTo(x2, y1); | |
| ctx.stroke(); | |
| // Bottom-left | |
| ctx.beginPath(); | |
| ctx.moveTo(x1 - len, y2); ctx.lineTo(x1, y2); | |
| ctx.moveTo(x1, y2 + len); ctx.lineTo(x1, y2); | |
| ctx.stroke(); | |
| // Bottom-right | |
| ctx.beginPath(); | |
| ctx.moveTo(x2 + len, y2); ctx.lineTo(x2, y2); | |
| ctx.moveTo(x2, y2 + len); ctx.lineTo(x2, y2); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| // Render the full sheet | |
| function render(){ | |
| const labels = getLabels(); | |
| const dpi = Math.max(72, Number(els.dpi.value) || 300); | |
| const pageKey = els.pageSize.value; | |
| const page = PAGE_SIZES[pageKey]; | |
| const marginMm = Math.max(0, Number(els.margin.value) || 10); | |
| // token size inputs | |
| const tokenW = Math.max(1, Number(els.tokenWidth.value) || 25.4); | |
| const tokenH = Math.max(1, Number(els.tokenHeight.value) || 25.4); | |
| const unit = els.unit.value; | |
| const fontFamily = els.fontFamily.value; | |
| const ctx = els.canvas.getContext('2d'); | |
| // Size canvas to page at DPI | |
| sizeCanvasForPage(ctx, pageKey, dpi); | |
| // Compute sizes in px | |
| const marginPx = mmToPx(marginMm, dpi); | |
| const tokenPx = tokenSizeToPx(tokenW, tokenH, unit, dpi); | |
| const tokenWpx = Math.floor(tokenPx.w); | |
| const tokenHpx = Math.floor(tokenPx.h); | |
| // corner radius | |
| const cornerMm = Math.max(0, Number(els.cornerRadius.value) || 0); | |
| const radiusPx = mmToPx(cornerMm, dpi); | |
| // Content area | |
| const contentW = els.canvas.width - marginPx * 2; | |
| const contentH = els.canvas.height - marginPx * 2; | |
| // Grid count | |
| const cols = Math.max(0, Math.floor(contentW / tokenWpx)); | |
| const rows = Math.max(0, Math.floor(contentH / tokenHpx)); | |
| const perPage = cols * rows; | |
| // Start origin | |
| const originX = Math.round(marginPx + (contentW - cols * tokenWpx) / 2); | |
| const originY = Math.round(marginPx + (contentH - rows * tokenHpx) / 2); | |
| // Outside margin cut lines | |
| if(els.cutMarks.checked){ | |
| drawOuterCutLines(ctx, marginPx, els.canvas.width, els.canvas.height); | |
| } | |
| // Background page content area outline (subtle) | |
| if(els.cutMarks.checked){ | |
| ctx.save(); | |
| ctx.strokeStyle = '#111827'; | |
| ctx.globalAlpha = 0.16; | |
| ctx.setLineDash([4, 6]); | |
| ctx.strokeRect(marginPx, marginPx, contentW, contentH); | |
| ctx.restore(); | |
| } | |
| // Draw tokens | |
| let drawn = 0; | |
| const options = { | |
| textColor: els.textColor.value, | |
| borderColor: els.borderColor.value, | |
| fillTop: els.fillTop.value, | |
| fillBottom: els.fillBottom.value, | |
| divider: dividerStyle, | |
| radiusPx | |
| }; | |
| for(let r=0; r<rows; r++){ | |
| for(let c=0; c<cols; c++){ | |
| const idx = r*cols + c; | |
| if(idx >= labels.length) break; | |
| const x = originX + c*tokenWpx; | |
| const y = originY + r*tokenHpx; | |
| // Optional token cut rectangles (inner grid guide) | |
| if(els.cutMarks.checked){ | |
| ctx.save(); | |
| ctx.strokeStyle = '#111827'; | |
| ctx.globalAlpha = 0.18; | |
| ctx.setLineDash([3,4]); | |
| ctx.strokeRect(x, y, tokenWpx, tokenHpx); | |
| ctx.restore(); | |
| } | |
| drawToken(ctx, x, y, tokenWpx, tokenHpx, labels[idx], fontFamily, options); | |
| drawn++; | |
| } | |
| } | |
| // Update stats | |
| els.statPage.textContent = `Page: ${page.label} • ${dpi} DPI • ${Math.round(els.canvas.width)}×${Math.round(els.canvas.height)} px`; | |
| els.statGrid.textContent = `Grid: ${cols} × ${rows} (${perPage} per page) • Token ${tokenW}×${tokenH} ${unit}`; | |
| els.statCount.textContent = `Tokens drawn: ${drawn}/${labels.length}`; | |
| // Enable exports if we rendered at least once | |
| const canExport = (cols > 0 && rows > 0); | |
| els.downloadPNGBtn.disabled = !canExport; | |
| els.downloadPDFBtn.disabled = !canExport; | |
| // Save state | |
| lastRender = { | |
| dpi, pageKey, marginMm, tokenW, tokenH, unit, fontFamily, cols, rows, perPage, labels, drawn, | |
| widthPx: els.canvas.width, heightPx: els.canvas.height | |
| }; | |
| } | |
| // Export PNG | |
| function downloadPNG(){ | |
| if(!lastRender) return; | |
| const dataURL = els.canvas.toDataURL("image/png"); | |
| const a = document.createElement('a'); | |
| a.href = dataURL; | |
| const page = PAGE_SIZES[lastRender.pageKey]; | |
| a.download = `tokens_${page.label.replace(/\s+/g,'').toLowerCase()}_${lastRender.dpi}dpi.png`; | |
| a.click(); | |
| } | |
| // Export PDF using jsPDF | |
| async function downloadPDF(){ | |
| if(!lastRender) return; | |
| const { jsPDF } = window.jspdf; | |
| const page = PAGE_SIZES[lastRender.pageKey]; | |
| const unit = "in"; | |
| const pdf = new jsPDF({ orientation: page.wIn > page.hIn ? 'landscape' : 'portrait', unit, format: [page.wIn, page.hIn] }); | |
| const dataURL = els.canvas.toDataURL("image/png"); | |
| pdf.addImage(dataURL, 'PNG', 0, 0, page.wIn, page.hIn); | |
| pdf.save(`tokens_${page.label.replace(/\s+/g,'').toLowerCase()}_${lastRender.dpi}dpi.pdf`); | |
| } | |
| // UI events | |
| els.renderBtn.addEventListener('click', render); | |
| els.downloadPNGBtn.addEventListener('click', downloadPNG); | |
| els.downloadPDFBtn.addEventListener('click', downloadPDF); | |
| // Apply preset buttons | |
| els.presetButtons.addEventListener('click', (e)=>{ | |
| if(e.target.closest('.chip')){ | |
| e.preventDefault(); | |
| const btn = e.target.closest('.chip'); | |
| const w = parseFloat(btn.dataset.w); | |
| const h = parseFloat(btn.dataset.h); | |
| const unit = btn.dataset.unit; | |
| els.tokenWidth.value = w; | |
| els.tokenHeight.value = h; | |
| els.unit.value = unit; | |
| pulse(btn); | |
| debouncedRender(); | |
| } | |
| }); | |
| // Divider toggle | |
| els.dividerToggle.addEventListener('click', (e)=>{ | |
| const b = e.target.closest('button'); | |
| if(!b) return; | |
| [...els.dividerToggle.querySelectorAll('button')].forEach(x=>x.classList.remove('active')); | |
| b.classList.add('active'); | |
| dividerStyle = b.dataset.div; | |
| debouncedRender(); | |
| }); | |
| // Label helpers | |
| els.shuffleBtn.addEventListener('click', ()=>{ | |
| const items = getLabels(); | |
| for(let i=items.length-1;i>0;i--){ | |
| const j = Math.floor(Math.random()*(i+1)); | |
| [items[i],items[j]] = [items[j],items[i]]; | |
| } | |
| els.labels.value = items.join('\n'); | |
| debouncedRender(); | |
| }); | |
| els.sortBtn.addEventListener('click', ()=>{ | |
| const items = getLabels().sort((a,b)=>a.localeCompare(b)); | |
| els.labels.value = items.join('\n'); | |
| debouncedRender(); | |
| }); | |
| els.clearBtn.addEventListener('click', ()=>{ | |
| if(confirm('Clear all labels?')){ | |
| els.labels.value = ''; | |
| debouncedRender(); | |
| } | |
| }); | |
| // Little pulse effect for feedback | |
| function pulse(el){ | |
| el.animate([ | |
| { transform: 'translateY(0)', filter: 'brightness(1)' }, | |
| { transform: 'translateY(-2px)', filter: 'brightness(1.15)' }, | |
| { transform: 'translateY(0)', filter: 'brightness(1)' } | |
| ], { duration: 240, easing: 'ease-out' }); | |
| } | |
| // Live preview on important changes (debounced) | |
| const debouncedRender = debounce(render, 200); | |
| ['labels','tokenWidth','tokenHeight','unit','fontFamily','pageSize','dpi','margin', | |
| 'cornerRadius','cutMarks','textColor','borderColor','fillTop','fillBottom'] | |
| .forEach(id => { | |
| const el = document.getElementById(id); | |
| el.addEventListener('input', debouncedRender); | |
| el.addEventListener('change', debouncedRender); | |
| }); | |
| function debounce(fn, wait){ | |
| let t; return (...args)=>{ | |
| clearTimeout(t); | |
| t = setTimeout(()=>fn.apply(this,args), wait); | |
| }; | |
| } | |
| // Initial demo content and render | |
| els.labels.value = [ | |
| 'Goblin Scout', | |
| 'Orc Warrior', | |
| 'Healing Potion', | |
| 'Treasure Chest', | |
| 'Boss: Ancient Dragon', | |
| 'Mystic Doorway', | |
| 'Trap: Poison Darts', | |
| 'Key Fragment', | |
| 'Arcane Crystal', | |
| 'Torch', | |
| 'Rogue Adept', | |
| 'Ancient Tome of Forgotten Realms' | |
| ].join('\n'); | |
| render(); | |
| </script> | |
| </body> | |
| </html> |