feat: QR export as SVG and PDF with selectable error correction
All QR endpoints (/api/qr/:code, /api/qr/custom GET+POST) accept format=png|svg|pdf, ec=L|M|Q|H, and download=1 for attachment responses, unified behind a shared qr_response() renderer. SVG is generated as a true vector (one path of modules, crispEdges) with an optional logo embedded as a data URI over a cleared centre tile; PDF wraps the raster render for print. A logo always forces error correction H. Dot styles remain raster-only. QR Studio: format and error-correction selectors, PDF previews as PNG, format-aware download button with contextual hints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -478,15 +478,27 @@ def get_user_monthly_clicks(conn, user_id):
|
|||||||
# QR Generator
|
# QR Generator
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _qr_ec_constant(qrc, ec: str, logo_bytes=None):
|
||||||
|
"""Map an L/M/Q/H letter to a qrcode error-correction constant.
|
||||||
|
A logo overlay always forces H so the covered modules stay recoverable."""
|
||||||
|
if logo_bytes:
|
||||||
|
return qrc.constants.ERROR_CORRECT_H
|
||||||
|
return {
|
||||||
|
'L': qrc.constants.ERROR_CORRECT_L,
|
||||||
|
'M': qrc.constants.ERROR_CORRECT_M,
|
||||||
|
'Q': qrc.constants.ERROR_CORRECT_Q,
|
||||||
|
'H': qrc.constants.ERROR_CORRECT_H,
|
||||||
|
}.get((ec or 'M').upper(), qrc.constants.ERROR_CORRECT_M)
|
||||||
|
|
||||||
|
|
||||||
def generate_qr_png(data: str, size: int = 300, fg=(0,0,0), bg=(255,255,255),
|
def generate_qr_png(data: str, size: int = 300, fg=(0,0,0), bg=(255,255,255),
|
||||||
style: str = 'square', logo_bytes: bytes = None) -> bytes:
|
style: str = 'square', logo_bytes: bytes = None, ec: str = 'M') -> bytes:
|
||||||
"""Generate QR PNG. Supports dot styles and logo overlay when qrcode[pil] is installed."""
|
"""Generate QR PNG. Supports dot styles and logo overlay when qrcode[pil] is installed."""
|
||||||
try:
|
try:
|
||||||
import qrcode as qrc
|
import qrcode as qrc
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
ec = qrc.constants.ERROR_CORRECT_H if logo_bytes else qrc.constants.ERROR_CORRECT_M
|
qr = qrc.QRCode(error_correction=_qr_ec_constant(qrc, ec, logo_bytes), border=2)
|
||||||
qr = qrc.QRCode(error_correction=ec, border=2)
|
|
||||||
qr.add_data(data)
|
qr.add_data(data)
|
||||||
qr.make(fit=True)
|
qr.make(fit=True)
|
||||||
|
|
||||||
@@ -591,6 +603,105 @@ def generate_qr_png(data: str, size: int = 300, fg=(0,0,0), bg=(255,255,255),
|
|||||||
+ png_chunk(b'IEND', b''))
|
+ png_chunk(b'IEND', b''))
|
||||||
|
|
||||||
|
|
||||||
|
def _sniff_image_mime(data: bytes) -> str:
|
||||||
|
if data[:8] == b'\x89PNG\r\n\x1a\n': return 'image/png'
|
||||||
|
if data[:2] == b'\xff\xd8': return 'image/jpeg'
|
||||||
|
if data[:6] in (b'GIF87a', b'GIF89a'): return 'image/gif'
|
||||||
|
if data[:4] == b'RIFF' and data[8:12] == b'WEBP': return 'image/webp'
|
||||||
|
return 'image/png'
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_svg(data: str, size: int = 300, fg: str = '#000000', bg: str = '#ffffff',
|
||||||
|
ec: str = 'M', logo_bytes: bytes = None) -> bytes:
|
||||||
|
"""Generate a vector QR as SVG (print-ready, scales losslessly).
|
||||||
|
A logo, when given, is embedded as a data URI over a cleared centre tile.
|
||||||
|
Dot styles are raster-only; SVG always renders square modules."""
|
||||||
|
import qrcode as qrc
|
||||||
|
qr = qrc.QRCode(error_correction=_qr_ec_constant(qrc, ec, logo_bytes), border=2)
|
||||||
|
qr.add_data(data)
|
||||||
|
qr.make(fit=True)
|
||||||
|
matrix = qr.get_matrix()
|
||||||
|
n = len(matrix)
|
||||||
|
path = ''.join(
|
||||||
|
f'M{x} {y}h1v1h-1z'
|
||||||
|
for y, row in enumerate(matrix)
|
||||||
|
for x, dark in enumerate(row) if dark
|
||||||
|
)
|
||||||
|
logo_svg = ''
|
||||||
|
if logo_bytes:
|
||||||
|
# Mirror the PNG renderer: clear a centre tile (logo = 1/4 of the code,
|
||||||
|
# plus a small gutter) and place the logo inside it.
|
||||||
|
logo_units = n / 4
|
||||||
|
pad = max(0.5, n / 100)
|
||||||
|
zone = logo_units + pad * 2
|
||||||
|
cx = (n - zone) / 2
|
||||||
|
b64 = base64.b64encode(logo_bytes).decode()
|
||||||
|
mime = _sniff_image_mime(logo_bytes)
|
||||||
|
logo_svg = (
|
||||||
|
f'<rect x="{cx:.2f}" y="{cx:.2f}" width="{zone:.2f}" height="{zone:.2f}" fill="{bg}"/>'
|
||||||
|
f'<image href="data:{mime};base64,{b64}" x="{cx + pad:.2f}" y="{cx + pad:.2f}" '
|
||||||
|
f'width="{logo_units:.2f}" height="{logo_units:.2f}" preserveAspectRatio="xMidYMid meet"/>'
|
||||||
|
)
|
||||||
|
svg = (
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {n} {n}" '
|
||||||
|
f'width="{size}" height="{size}" shape-rendering="crispEdges">'
|
||||||
|
f'<rect width="{n}" height="{n}" fill="{bg}"/>'
|
||||||
|
f'<path d="{path}" fill="{fg}"/>{logo_svg}</svg>'
|
||||||
|
)
|
||||||
|
return svg.encode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def png_to_pdf(png_bytes: bytes) -> bytes:
|
||||||
|
"""Wrap a rendered QR PNG in a single-page PDF (requires Pillow)."""
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(io.BytesIO(png_bytes)).convert('RGB')
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, 'PDF')
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def qr_response(data_str: str, params, logo_bytes: bytes = None,
|
||||||
|
filename: str = 'qr', cache: bool = False):
|
||||||
|
"""Shared QR endpoint renderer — handles format (png/svg/pdf), colours,
|
||||||
|
size, style, error-correction, and optional attachment download."""
|
||||||
|
fmt = (params.get('format') or 'png').lower()
|
||||||
|
ec = (params.get('ec') or 'M').upper()
|
||||||
|
fg_hex = (params.get('fg') or '000000').lstrip('#')
|
||||||
|
bg_hex = (params.get('bg') or 'ffffff').lstrip('#')
|
||||||
|
size = min(int(params.get('size', 300) or 300), 1000)
|
||||||
|
style = params.get('style') or 'square'
|
||||||
|
download = str(params.get('download') or '').lower() in ('1', 'true')
|
||||||
|
|
||||||
|
if fmt == 'svg':
|
||||||
|
try:
|
||||||
|
body = generate_qr_svg(data_str, size=size, fg='#'+fg_hex, bg='#'+bg_hex,
|
||||||
|
ec=ec, logo_bytes=logo_bytes)
|
||||||
|
except ImportError:
|
||||||
|
return jsonify({'error': 'SVG output requires the qrcode library'}), 501
|
||||||
|
mime, ext = 'image/svg+xml', 'svg'
|
||||||
|
elif fmt == 'pdf':
|
||||||
|
png = generate_qr_png(data_str, size=size, fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex),
|
||||||
|
style=style, logo_bytes=logo_bytes, ec=ec)
|
||||||
|
try:
|
||||||
|
body = png_to_pdf(png)
|
||||||
|
except ImportError:
|
||||||
|
return jsonify({'error': 'PDF output requires Pillow'}), 501
|
||||||
|
mime, ext = 'application/pdf', 'pdf'
|
||||||
|
elif fmt == 'png':
|
||||||
|
body = generate_qr_png(data_str, size=size, fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex),
|
||||||
|
style=style, logo_bytes=logo_bytes, ec=ec)
|
||||||
|
mime, ext = 'image/png', 'png'
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'format must be png, svg, or pdf'}), 400
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if cache:
|
||||||
|
headers['Cache-Control'] = 'public, max-age=3600'
|
||||||
|
if download:
|
||||||
|
headers['Content-Disposition'] = f'attachment; filename="{filename}.{ext}"'
|
||||||
|
return Response(body, mimetype=mime, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Shared helpers
|
# Shared helpers
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -1372,19 +1483,17 @@ def stats():
|
|||||||
# QR Routes
|
# QR Routes
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# All QR endpoints accept: format=png|svg|pdf, ec=L|M|Q|H, fg, bg, size,
|
||||||
|
# style (raster formats only), download=1 for an attachment response.
|
||||||
|
|
||||||
@app.route('/api/qr/<code>')
|
@app.route('/api/qr/<code>')
|
||||||
def qr_code(code):
|
def qr_code(code):
|
||||||
fg_hex = request.args.get('fg', '000000')
|
|
||||||
bg_hex = request.args.get('bg', 'ffffff')
|
|
||||||
size = min(int(request.args.get('size', 300)), 1000)
|
|
||||||
style = request.args.get('style', 'square')
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
link = conn.execute('SELECT 1 FROM links WHERE code=? AND is_active=1', (code,)).fetchone()
|
link = conn.execute('SELECT 1 FROM links WHERE code=? AND is_active=1', (code,)).fetchone()
|
||||||
if not link:
|
if not link:
|
||||||
return jsonify({'error': 'Not found'}), 404
|
return jsonify({'error': 'Not found'}), 404
|
||||||
png = generate_qr_png(f"{BASE_URL}/{code}", size=size,
|
return qr_response(f"{BASE_URL}/{code}", request.args,
|
||||||
fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex), style=style)
|
filename=f'qr-{code}', cache=True)
|
||||||
return Response(png, mimetype='image/png', headers={'Cache-Control': 'public, max-age=3600'})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/qr/custom', methods=['GET'])
|
@app.route('/api/qr/custom', methods=['GET'])
|
||||||
@@ -1392,12 +1501,7 @@ def qr_custom():
|
|||||||
url = request.args.get('url', '').strip()
|
url = request.args.get('url', '').strip()
|
||||||
if not url or not validate_url(url):
|
if not url or not validate_url(url):
|
||||||
return jsonify({'error': 'Valid URL required'}), 400
|
return jsonify({'error': 'Valid URL required'}), 400
|
||||||
fg_hex = request.args.get('fg', '000000')
|
return qr_response(url, request.args, filename='qrcode')
|
||||||
bg_hex = request.args.get('bg', 'ffffff')
|
|
||||||
size = min(int(request.args.get('size', 300)), 1000)
|
|
||||||
style = request.args.get('style', 'square')
|
|
||||||
png = generate_qr_png(url, size=size, fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex), style=style)
|
|
||||||
return Response(png, mimetype='image/png')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/qr/custom', methods=['POST'])
|
@app.route('/api/qr/custom', methods=['POST'])
|
||||||
@@ -1406,10 +1510,6 @@ def qr_custom_post():
|
|||||||
url = (data.get('url') or '').strip()
|
url = (data.get('url') or '').strip()
|
||||||
if not url or not validate_url(url):
|
if not url or not validate_url(url):
|
||||||
return jsonify({'error': 'Valid URL required'}), 400
|
return jsonify({'error': 'Valid URL required'}), 400
|
||||||
fg_hex = (data.get('fg') or '000000').lstrip('#')
|
|
||||||
bg_hex = (data.get('bg') or 'ffffff').lstrip('#')
|
|
||||||
size = min(int(data.get('size', 300)), 1000)
|
|
||||||
style = data.get('style', 'square')
|
|
||||||
logo_bytes = None
|
logo_bytes = None
|
||||||
logo_b64 = data.get('logo', '')
|
logo_b64 = data.get('logo', '')
|
||||||
if logo_b64:
|
if logo_b64:
|
||||||
@@ -1417,9 +1517,7 @@ def qr_custom_post():
|
|||||||
logo_bytes = base64.b64decode(logo_b64)
|
logo_bytes = base64.b64decode(logo_b64)
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({'error': 'Invalid logo data'}), 400
|
return jsonify({'error': 'Invalid logo data'}), 400
|
||||||
png = generate_qr_png(url, size=size, fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex),
|
return qr_response(url, data, logo_bytes=logo_bytes, filename='qrcode')
|
||||||
style=style, logo_bytes=logo_bytes)
|
|
||||||
return Response(png, mimetype='image/png')
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|||||||
+70
-19
@@ -697,6 +697,26 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
|
|||||||
<button class="style-btn" data-style="horizontal">▬ H-Bars</button>
|
<button class="style-btn" data-style="horizontal">▬ H-Bars</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="color-row" style="margin-bottom:18px">
|
||||||
|
<div>
|
||||||
|
<label class="field-label">FORMAT</label>
|
||||||
|
<select class="field-input" id="qr-format">
|
||||||
|
<option value="png" selected>PNG — raster</option>
|
||||||
|
<option value="svg">SVG — vector (print)</option>
|
||||||
|
<option value="pdf">PDF — print-ready</option>
|
||||||
|
</select>
|
||||||
|
<div style="font-size:10px;color:var(--muted);font-family:var(--font-mono);margin-top:4px">Dot styles apply to PNG & PDF only</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label">ERROR CORRECTION</label>
|
||||||
|
<select class="field-input" id="qr-ec">
|
||||||
|
<option value="L">L — 7% (densest)</option>
|
||||||
|
<option value="M" selected>M — 15% (default)</option>
|
||||||
|
<option value="Q">Q — 25%</option>
|
||||||
|
<option value="H">H — 30% (logos)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style="margin-bottom:18px">
|
<div style="margin-bottom:18px">
|
||||||
<label class="field-label">LOGO / ICON <span style="font-weight:400;color:var(--muted)">(optional — centered overlay)</span></label>
|
<label class="field-label">LOGO / ICON <span style="font-weight:400;color:var(--muted)">(optional — centered overlay)</span></label>
|
||||||
<div class="logo-row">
|
<div class="logo-row">
|
||||||
@@ -713,7 +733,7 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
|
|||||||
<div id="qr-preview-wrap"><div class="qr-placeholder">Enter a URL and click Generate</div></div>
|
<div id="qr-preview-wrap"><div class="qr-placeholder">Enter a URL and click Generate</div></div>
|
||||||
<div id="qr-dl-row" style="display:none;flex-direction:column;align-items:center;gap:8px;width:100%">
|
<div id="qr-dl-row" style="display:none;flex-direction:column;align-items:center;gap:8px;width:100%">
|
||||||
<a class="btn-primary" id="qr-download-link" download="qrcode.png" style="text-decoration:none;display:inline-block;text-align:center">↓ Download PNG</a>
|
<a class="btn-primary" id="qr-download-link" download="qrcode.png" style="text-decoration:none;display:inline-block;text-align:center">↓ Download PNG</a>
|
||||||
<div style="font-size:11px;color:var(--muted);font-family:var(--font-mono)">Right-click to save</div>
|
<div style="font-size:11px;color:var(--muted);font-family:var(--font-mono)" id="qr-dl-hint">Right-click to save</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -722,6 +742,22 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
|
|||||||
|
|
||||||
<!-- ── ADMIN ── -->
|
<!-- ── ADMIN ── -->
|
||||||
<div class="view" id="view-admin">
|
<div class="view" id="view-admin">
|
||||||
|
<!-- Platform overview -->
|
||||||
|
<div class="section-head" style="margin-bottom:18px">
|
||||||
|
<div><div class="section-title">Platform Overview</div><div style="color:var(--muted);font-size:13px;margin-top:3px">All users, links, and clicks across the instance</div></div>
|
||||||
|
<button class="btn-sm" onclick="loadAdminOverview()">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="stats-strip" id="admin-stats">
|
||||||
|
<div class="stat-card"><div class="stat-value" id="astat-users">—</div><div class="stat-label">USERS</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" id="astat-orgs">—</div><div class="stat-label">ORGS</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" id="astat-links">—</div><div class="stat-label">ACTIVE LINKS</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" id="astat-clicks30">—</div><div class="stat-label">CLICKS / 30D</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" style="margin-bottom:32px">
|
||||||
|
<h3>QUOTA USAGE</h3>
|
||||||
|
<div id="admin-usage"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Users -->
|
<!-- Users -->
|
||||||
<div class="section-head" style="margin-bottom:24px">
|
<div class="section-head" style="margin-bottom:24px">
|
||||||
<div><div class="section-title">User Management</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Create and manage user accounts</div></div>
|
<div><div class="section-title">User Management</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Create and manage user accounts</div></div>
|
||||||
@@ -1831,37 +1867,52 @@ function clearQrLogo() {
|
|||||||
document.getElementById('qr-logo-clear').style.display = 'none';
|
document.getElementById('qr-logo-clear').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function postQrBlob(body) {
|
||||||
|
const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)});
|
||||||
|
if (!resp.ok) throw new Error('QR generation failed');
|
||||||
|
return await resp.blob();
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('qr-generate-btn').addEventListener('click', async () => {
|
document.getElementById('qr-generate-btn').addEventListener('click', async () => {
|
||||||
const url = document.getElementById('qr-url-input').value.trim();
|
const url = document.getElementById('qr-url-input').value.trim();
|
||||||
if (!url) { toast('Please enter a URL','error'); return; }
|
if (!url) { toast('Please enter a URL','error'); return; }
|
||||||
const fg = qrFgH.value.replace('#','') || '000000';
|
const fg = qrFgH.value.replace('#','') || '000000';
|
||||||
const bg = qrBgH.value.replace('#','') || 'ffffff';
|
const bg = qrBgH.value.replace('#','') || 'ffffff';
|
||||||
const size = parseInt(qrSz.value);
|
const size = parseInt(qrSz.value);
|
||||||
|
const fmt = document.getElementById('qr-format').value;
|
||||||
|
const ec = document.getElementById('qr-ec').value;
|
||||||
const btn = document.getElementById('qr-generate-btn');
|
const btn = document.getElementById('qr-generate-btn');
|
||||||
|
const dlLink = document.getElementById('qr-download-link');
|
||||||
|
// PDFs can't render in an <img> — preview those as PNG, download as PDF
|
||||||
|
const previewFmt = fmt === 'pdf' ? 'png' : fmt;
|
||||||
|
|
||||||
if (qrLogoBase64 || qrStyle !== 'square') {
|
|
||||||
btn.disabled = true; btn.textContent = 'Generating...';
|
btn.disabled = true; btn.textContent = 'Generating...';
|
||||||
try {
|
try {
|
||||||
const body = { url, fg, bg, size, style: qrStyle };
|
let previewUrl, dlUrl;
|
||||||
if (qrLogoBase64) body.logo = qrLogoBase64;
|
if (qrLogoBase64) {
|
||||||
const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)});
|
const params = { url, fg, bg, size, style: qrStyle, ec, logo: qrLogoBase64 };
|
||||||
if (!resp.ok) { toast('Failed to generate QR','error'); return; }
|
const previewBlob = await postQrBlob({...params, format: previewFmt});
|
||||||
const blob = await resp.blob();
|
previewUrl = URL.createObjectURL(previewBlob);
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
dlUrl = fmt === 'pdf'
|
||||||
document.getElementById('qr-preview-wrap').innerHTML =
|
? URL.createObjectURL(await postQrBlob({...params, format: 'pdf'}))
|
||||||
`<div class="qr-preview-img"><img src="${blobUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
|
: previewUrl;
|
||||||
document.getElementById('qr-dl-row').style.display = 'flex';
|
|
||||||
document.getElementById('qr-download-link').href = blobUrl;
|
|
||||||
} catch(e) { toast('Network error','error'); }
|
|
||||||
finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; }
|
|
||||||
} else {
|
} else {
|
||||||
const qrUrl = `${BASE}/api/qr/custom?url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&t=${Date.now()}`;
|
const qs = `url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&style=${qrStyle}&ec=${ec}`;
|
||||||
document.getElementById('qr-preview-wrap').innerHTML =
|
previewUrl = `${BASE}/api/qr/custom?${qs}&format=${previewFmt}&t=${Date.now()}`;
|
||||||
`<div class="qr-preview-img"><img src="${qrUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
|
dlUrl = `${BASE}/api/qr/custom?${qs}&format=${fmt}&download=1`;
|
||||||
document.getElementById('qr-dl-row').style.display = 'flex';
|
|
||||||
document.getElementById('qr-download-link').href = qrUrl;
|
|
||||||
}
|
}
|
||||||
|
document.getElementById('qr-preview-wrap').innerHTML =
|
||||||
|
`<div class="qr-preview-img"><img src="${previewUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
|
||||||
|
document.getElementById('qr-dl-row').style.display = 'flex';
|
||||||
|
dlLink.href = dlUrl;
|
||||||
|
dlLink.download = `qrcode.${fmt}`;
|
||||||
|
dlLink.textContent = `↓ Download ${fmt.toUpperCase()}`;
|
||||||
|
document.getElementById('qr-dl-hint').textContent =
|
||||||
|
fmt === 'svg' ? 'Vector — scales losslessly for print' :
|
||||||
|
fmt === 'pdf' ? 'Print-ready single-page PDF' : 'Right-click to save';
|
||||||
toast('QR generated!');
|
toast('QR generated!');
|
||||||
|
} catch(e) { toast('Failed to generate QR','error'); }
|
||||||
|
finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Select Mode & Bulk Ops ─────────────────────────
|
// ── Select Mode & Bulk Ops ─────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user