diff --git a/app.py b/app.py index 9d266bc..20a1f03 100644 --- a/app.py +++ b/app.py @@ -478,15 +478,27 @@ def get_user_monthly_clicks(conn, user_id): # 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), - 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.""" try: import qrcode as qrc from PIL import Image - ec = qrc.constants.ERROR_CORRECT_H if logo_bytes else qrc.constants.ERROR_CORRECT_M - qr = qrc.QRCode(error_correction=ec, border=2) + qr = qrc.QRCode(error_correction=_qr_ec_constant(qrc, ec, logo_bytes), border=2) qr.add_data(data) 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'')) +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'' + f'' + ) + svg = ( + f'' + f'' + f'{logo_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 # ───────────────────────────────────────────── @@ -1372,19 +1483,17 @@ def stats(): # 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/') 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: link = conn.execute('SELECT 1 FROM links WHERE code=? AND is_active=1', (code,)).fetchone() if not link: return jsonify({'error': 'Not found'}), 404 - png = generate_qr_png(f"{BASE_URL}/{code}", size=size, - fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex), style=style) - return Response(png, mimetype='image/png', headers={'Cache-Control': 'public, max-age=3600'}) + return qr_response(f"{BASE_URL}/{code}", request.args, + filename=f'qr-{code}', cache=True) @app.route('/api/qr/custom', methods=['GET']) @@ -1392,12 +1501,7 @@ def qr_custom(): url = request.args.get('url', '').strip() if not url or not validate_url(url): return jsonify({'error': 'Valid URL required'}), 400 - 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') - 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') + return qr_response(url, request.args, filename='qrcode') @app.route('/api/qr/custom', methods=['POST']) @@ -1406,10 +1510,6 @@ def qr_custom_post(): url = (data.get('url') or '').strip() if not url or not validate_url(url): 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_b64 = data.get('logo', '') if logo_b64: @@ -1417,9 +1517,7 @@ def qr_custom_post(): logo_bytes = base64.b64decode(logo_b64) except Exception: 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), - style=style, logo_bytes=logo_bytes) - return Response(png, mimetype='image/png') + return qr_response(url, data, logo_bytes=logo_bytes, filename='qrcode') # ───────────────────────────────────────────── diff --git a/index.html b/index.html index 7a0d517..7582eae 100644 --- a/index.html +++ b/index.html @@ -697,6 +697,26 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px; +
+
+ + +
Dot styles apply to PNG & PDF only
+
+
+ + +
+
@@ -713,7 +733,7 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
Enter a URL and click Generate
@@ -722,6 +742,22 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
+ +
+
Platform Overview
All users, links, and clicks across the instance
+ +
+
+
USERS
+
ORGS
+
ACTIVE LINKS
+
CLICKS / 30D
+
+
+

QUOTA USAGE

+
Loading…
+
+
User Management
Create and manage user accounts
@@ -1831,37 +1867,52 @@ function clearQrLogo() { 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 () => { const url = document.getElementById('qr-url-input').value.trim(); if (!url) { toast('Please enter a URL','error'); return; } const fg = qrFgH.value.replace('#','') || '000000'; const bg = qrBgH.value.replace('#','') || 'ffffff'; 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 dlLink = document.getElementById('qr-download-link'); + // PDFs can't render in an — preview those as PNG, download as PDF + const previewFmt = fmt === 'pdf' ? 'png' : fmt; - if (qrLogoBase64 || qrStyle !== 'square') { - btn.disabled = true; btn.textContent = 'Generating...'; - try { - const body = { url, fg, bg, size, style: qrStyle }; - if (qrLogoBase64) body.logo = qrLogoBase64; - const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)}); - if (!resp.ok) { toast('Failed to generate QR','error'); return; } - const blob = await resp.blob(); - const blobUrl = URL.createObjectURL(blob); - document.getElementById('qr-preview-wrap').innerHTML = - `
QR
`; - 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 { - const qrUrl = `${BASE}/api/qr/custom?url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&t=${Date.now()}`; + btn.disabled = true; btn.textContent = 'Generating...'; + try { + let previewUrl, dlUrl; + if (qrLogoBase64) { + const params = { url, fg, bg, size, style: qrStyle, ec, logo: qrLogoBase64 }; + const previewBlob = await postQrBlob({...params, format: previewFmt}); + previewUrl = URL.createObjectURL(previewBlob); + dlUrl = fmt === 'pdf' + ? URL.createObjectURL(await postQrBlob({...params, format: 'pdf'})) + : previewUrl; + } else { + const qs = `url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&style=${qrStyle}&ec=${ec}`; + previewUrl = `${BASE}/api/qr/custom?${qs}&format=${previewFmt}&t=${Date.now()}`; + dlUrl = `${BASE}/api/qr/custom?${qs}&format=${fmt}&download=1`; + } document.getElementById('qr-preview-wrap').innerHTML = - `
QR
`; + `
QR
`; document.getElementById('qr-dl-row').style.display = 'flex'; - document.getElementById('qr-download-link').href = qrUrl; - } - toast('QR generated!'); + 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!'); + } catch(e) { toast('Failed to generate QR','error'); } + finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; } }); // ── Select Mode & Bulk Ops ─────────────────────────