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:
Jason Stedwell
2026-07-03 01:39:50 -05:00
parent 14d6e8c50e
commit a1cb6ec5f2
2 changed files with 194 additions and 45 deletions
+121 -23
View File
@@ -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'<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
# ─────────────────────────────────────────────
@@ -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/<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:
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')
# ─────────────────────────────────────────────