feat: auto-convert URL to QR code in PDF export and preview
This commit is contained in:
@@ -832,6 +832,29 @@ def preview_report(plan_id):
|
|||||||
# Markdown 转 HTML
|
# Markdown 转 HTML
|
||||||
html_content = markdown.markdown(rendered, extensions=['tables', 'fenced_code'])
|
html_content = markdown.markdown(rendered, extensions=['tables', 'fenced_code'])
|
||||||
|
|
||||||
|
# URL 转二维码图片(预览用)
|
||||||
|
import re
|
||||||
|
import qrcode
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
def make_qr_base64(url):
|
||||||
|
qr = qrcode.make(url, box_size=5)
|
||||||
|
buf = BytesIO()
|
||||||
|
qr.save(buf, format='PNG')
|
||||||
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
def replace_url_with_qr(match):
|
||||||
|
url = match.group(0)
|
||||||
|
try:
|
||||||
|
qr_b64 = make_qr_base64(url)
|
||||||
|
return f'<br><img src="data:image/png;base64,{qr_b64}" alt="QR" style="width:80px;display:block;margin:8px auto;">'
|
||||||
|
except:
|
||||||
|
return url
|
||||||
|
|
||||||
|
url_pattern = re.compile(r'https?://[^\s<>"{}|\\^`\[\]]+')
|
||||||
|
html_content = url_pattern.sub(replace_url_with_qr, html_content)
|
||||||
|
|
||||||
# 支持 ReportLab <para alignment="center"> 语法,转为 HTML
|
# 支持 ReportLab <para alignment="center"> 语法,转为 HTML
|
||||||
import re
|
import re
|
||||||
html_content = re.sub(
|
html_content = re.sub(
|
||||||
|
|||||||
@@ -53,6 +53,26 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
CHINESE_FONT_OK = False
|
CHINESE_FONT_OK = False
|
||||||
|
|
||||||
|
import re
|
||||||
|
import qrcode
|
||||||
|
from io import BytesIO
|
||||||
|
from reportlab.platypus import Image as RLImage
|
||||||
|
|
||||||
|
# URL 正则表达式
|
||||||
|
URL_PATTERN = re.compile(r'https?://[^\s<>"{}|\\^`\[\]]+')
|
||||||
|
|
||||||
|
def generate_qr_image(url, size=50*mm):
|
||||||
|
"""生成二维码图片,返回 BytesIO 对象"""
|
||||||
|
qr = qrcode.make(url, box_size=10)
|
||||||
|
buf = BytesIO()
|
||||||
|
qr.save(buf, format='PNG')
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def contains_url(text):
|
||||||
|
"""检测文本是否包含 URL"""
|
||||||
|
return bool(URL_PATTERN.search(text))
|
||||||
|
|
||||||
def md_to_xml(text):
|
def md_to_xml(text):
|
||||||
"""将markdown转换为reportlab XML markup"""
|
"""将markdown转换为reportlab XML markup"""
|
||||||
if not text:
|
if not text:
|
||||||
@@ -169,7 +189,25 @@ class PianoPDF:
|
|||||||
self.elements.append(Paragraph(md_to_xml(text), self.heading_style))
|
self.elements.append(Paragraph(md_to_xml(text), self.heading_style))
|
||||||
|
|
||||||
def add_paragraph(self, text):
|
def add_paragraph(self, text):
|
||||||
if text:
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检测是否是纯 URL
|
||||||
|
url_match = URL_PATTERN.match(text.strip())
|
||||||
|
if url_match and url_match.group() == text.strip():
|
||||||
|
# 纯 URL,生成二维码
|
||||||
|
url = url_match.group()
|
||||||
|
try:
|
||||||
|
buf = generate_qr_image(url, 50*mm)
|
||||||
|
img = RLImage(buf, width=50*mm, height=50*mm)
|
||||||
|
img.hAlign = 'CENTER'
|
||||||
|
self.elements.append(img)
|
||||||
|
self.elements.append(Spacer(1, 2*mm))
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
# QR生成失败,回退到文字
|
||||||
|
pass
|
||||||
|
|
||||||
self.elements.append(Paragraph(md_to_xml(text), self.body_style))
|
self.elements.append(Paragraph(md_to_xml(text), self.body_style))
|
||||||
self.elements.append(Spacer(1, 1*mm))
|
self.elements.append(Spacer(1, 1*mm))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from app import create_app, db
|
||||||
|
from app.models import PracticePlan
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
plans = PracticePlan.query.order_by(db.desc("created_at")).limit(3).all()
|
||||||
|
for p in plans:
|
||||||
|
print(f"ID:{p.id} | created_at:{p.created_at} | student:{p.student.name if p.student else 'N/A'}")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from app import create_app, db
|
||||||
|
from app.models import PracticePlan
|
||||||
|
|
||||||
|
# Query local dev database (same data as production)
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
plans = PracticePlan.query.order_by(db.desc("created_at")).limit(5).all()
|
||||||
|
for p in plans:
|
||||||
|
print(f"ID:{p.id} | created_at:{p.created_at} | student:{p.student.name if p.student else 'N/A'}")
|
||||||
@@ -5,3 +5,5 @@ Jinja2==3.1.3
|
|||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
markdown>=3.4
|
markdown>=3.4
|
||||||
|
qrcode>=7.4
|
||||||
|
Pillow>=10.0
|
||||||
Reference in New Issue
Block a user