18 KiB
Report PDF/MD Download Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Afegir descàrrega del report generat en format MD i PDF des del frontend, amb un botó desplegable que apareix quan el report s'ha completat.
Architecture: S'estén l'endpoint de descàrrega existent GET /api/report/<id>/download amb el paràmetre ?format=md|pdf. El backend converteix full_report.md → HTML (via markdown) → PDF (via PyMuPDF / fitz.Story). El frontend afegeix un botó desplegable a Step4Report.vue amb opcions MD i PDF que obren una URL de descàrrega directa.
Tech Stack: Python markdown>=3.6 (nova dep), PyMuPDF>=1.24.0 (ja instal·lat), Vue 3 SPA.
Arxius afectats
| Arxiu | Canvi |
|---|---|
backend/pyproject.toml |
+"markdown>=3.6" a dependencies |
backend/app/api/report.py |
Ampliar download_report() amb ?format + generar PDF |
frontend/src/api/report.js |
+getReportDownloadUrl(reportId, format) |
frontend/src/components/Step4Report.vue |
+botó desplegable MD/PDF quan isComplete |
Task 1: Afegir dependència markdown al backend
Files:
-
Modify:
backend/pyproject.toml -
Step 1: Afegir la dependència
A backend/pyproject.toml, afegir "markdown>=3.6" a la llista dependencies, just després de "PyMuPDF>=1.24.0":
# 文件处理
"PyMuPDF>=1.24.0",
"markdown>=3.6",
- Step 2: Instal·lar la dependència
cd backend && uv sync
Resultat esperat: markdown s'instal·la sense errors.
- Step 3: Verificar que s'importa correctament
cd backend && uv run python -c "import markdown; print(markdown.__version__)"
Resultat esperat: imprimeix una versió ≥ 3.6 (p.ex. 3.7).
- Step 4: Commit
git add backend/pyproject.toml backend/uv.lock
git commit -m "chore(deps): add markdown>=3.6 for PDF generation"
Task 2: Escriure el test de l'endpoint de descàrrega PDF
Files:
-
Test:
backend/tests/test_report_download.py -
Step 1: Escriure el test
Crear el fitxer backend/tests/test_report_download.py:
"""Tests for report download endpoint (MD and PDF formats)."""
import io
import os
import json
import tempfile
import pytest
from unittest.mock import patch, MagicMock
from app import create_app
@pytest.fixture
def app():
app = create_app({'TESTING': True})
yield app
@pytest.fixture
def client(app):
return app.test_client()
def _make_mock_report(report_id="report_test123", content="# Test Report\n\nHello **world**."):
mock = MagicMock()
mock.report_id = report_id
mock.markdown_content = content
return mock
def _make_md_file(tmp_path, report_id, content):
md_path = os.path.join(tmp_path, f"{report_id}_full_report.md")
with open(md_path, 'w', encoding='utf-8') as f:
f.write(content)
return md_path
class TestDownloadMD:
def test_download_md_format_param(self, client, tmp_path):
"""?format=md returns a .md file."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=md')
assert resp.status_code == 200
assert 'attachment' in resp.headers.get('Content-Disposition', '')
assert '.md' in resp.headers.get('Content-Disposition', '')
def test_download_default_is_md(self, client, tmp_path):
"""No format param defaults to md."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download')
assert resp.status_code == 200
assert '.md' in resp.headers.get('Content-Disposition', '')
class TestDownloadPDF:
def test_download_pdf_returns_pdf_bytes(self, client, tmp_path):
"""?format=pdf returns a valid PDF file."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=pdf')
assert resp.status_code == 200
assert resp.headers.get('Content-Type', '').startswith('application/pdf')
assert resp.data[:4] == b'%PDF'
def test_download_pdf_report_not_found(self, client):
"""Returns 404 when report does not exist."""
with patch('app.api.report.ReportManager.get_report', return_value=None):
resp = client.get('/api/report/nonexistent/download?format=pdf')
assert resp.status_code == 404
def test_download_pdf_invalid_format(self, client, tmp_path):
"""Returns 400 for unknown format parameter."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=docx')
assert resp.status_code == 400
- Step 2: Executar els tests per verificar que fallen
cd backend && uv run pytest tests/test_report_download.py -v 2>&1 | head -40
Resultat esperat: FAILED (la nova funcionalitat PDF encara no existeix).
Task 3: Implementar la generació de PDF al backend
Files:
-
Modify:
backend/app/api/report.py(funciódownload_report, línies 398–441) -
Step 1: Afegir imports al capdamunt de
report.py
Localitza el bloc d'imports a backend/app/api/report.py (línies 1–19) i afegeix:
import io
import tempfile
import markdown as md_lib
import fitz # PyMuPDF
Si import io ja existeix, no el dupliquis. Afegeix els que faltin just després de import traceback.
- Step 2: Afegir la funció helper
_generate_pdf_bytes
Afegeix aquesta funció just abans de @report_bp.route('/<report_id>/download', ...) (línia 398):
def _generate_pdf_bytes(markdown_content: str) -> bytes:
"""Convert Markdown string to PDF bytes using PyMuPDF (fitz.Story)."""
html_body = md_lib.markdown(
markdown_content,
extensions=['tables', 'fenced_code']
)
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: sans-serif; font-size: 12pt; line-height: 1.6;
margin: 40px; color: #1a1a1a; }}
h1 {{ font-size: 22pt; border-bottom: 2px solid #333; padding-bottom: 6px; }}
h2 {{ font-size: 16pt; margin-top: 28px; }}
h3 {{ font-size: 13pt; }}
pre {{ background: #f4f4f4; padding: 10px; border-radius: 4px;
font-size: 10pt; overflow-x: auto; }}
code {{ background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }}
blockquote {{ border-left: 3px solid #aaa; margin-left: 0;
padding-left: 12px; color: #555; }}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
th {{ background: #f0f0f0; }}
</style>
</head>
<body>{html_body}</body>
</html>"""
story = fitz.Story(html)
buf = io.BytesIO()
writer = fitz.DocumentWriter(buf)
mediabox = fitz.paper_rect("a4")
where = mediabox + (36, 36, -36, -36) # margins
more = True
while more:
device = writer.begin_page(mediabox)
more, _ = story.place(where)
story.draw(device)
writer.end_page()
writer.close()
return buf.getvalue()
- Step 3: Substituir la funció
download_reportcompleta
Substitueix tot el contingut de download_report (línies 398–441) per:
@report_bp.route('/<report_id>/download', methods=['GET'])
def download_report(report_id: str):
"""
Download report in the requested format.
Query params:
format: 'md' (default) | 'pdf'
"""
try:
fmt = request.args.get('format', 'md').lower()
if fmt not in ('md', 'pdf'):
return jsonify({
"success": False,
"error": f"Unsupported format '{fmt}'. Use 'md' or 'pdf'."
}), 400
report = ReportManager.get_report(report_id)
if not report:
return jsonify({
"success": False,
"error": t('api.reportNotFound', id=report_id)
}), 404
md_path = ReportManager._get_report_markdown_path(report_id)
if os.path.exists(md_path):
with open(md_path, 'r', encoding='utf-8') as f:
markdown_content = f.read()
else:
markdown_content = report.markdown_content
if fmt == 'md':
if os.path.exists(md_path):
return send_file(
md_path,
as_attachment=True,
download_name=f"{report_id}.md"
)
with tempfile.NamedTemporaryFile(mode='w', suffix='.md',
delete=False, encoding='utf-8') as f:
f.write(markdown_content)
temp_path = f.name
return send_file(temp_path, as_attachment=True,
download_name=f"{report_id}.md")
# fmt == 'pdf'
pdf_bytes = _generate_pdf_bytes(markdown_content)
return send_file(
io.BytesIO(pdf_bytes),
mimetype='application/pdf',
as_attachment=True,
download_name=f"{report_id}.pdf"
)
except Exception as e:
logger.error(f"Failed to download report: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
- Step 4: Executar els tests
cd backend && uv run pytest tests/test_report_download.py -v
Resultat esperat: tots els tests en PASS.
- Step 5: Commit
git add backend/app/api/report.py backend/tests/test_report_download.py
git commit -m "feat(report): add PDF download endpoint via PyMuPDF"
Task 4: Afegir helper al frontend API
Files:
-
Modify:
frontend/src/api/report.js -
Step 1: Afegir
getReportDownloadUrlal final dereport.js
Afegeix al final de frontend/src/api/report.js:
/**
* Build the direct download URL for a report.
* @param {string} reportId
* @param {'md'|'pdf'} format
* @returns {string} URL absoluta per fer servir com a href de descàrrega
*/
export const getReportDownloadUrl = (reportId, format = 'md') => {
const base = import.meta.env.VITE_API_BASE_URL || ''
return `${base}/api/report/${reportId}/download?format=${format}`
}
- Step 2: Verificar que el frontend compila sense errors
cd frontend && npm run build 2>&1 | tail -20
Resultat esperat: build sense errors.
- Step 3: Commit
git add frontend/src/api/report.js
git commit -m "feat(report): add getReportDownloadUrl helper"
Task 5: Afegir botó desplegable MD/PDF al component Vue
Files:
- Modify:
frontend/src/components/Step4Report.vue
Els canvis s'estructuren en 3 sub-passos: (a) importar la funció, (b) afegir el template, (c) afegir els estils.
- Step 1: Afegir l'import de
getReportDownloadUrl
A Step4Report.vue, localitza la línia d'imports de report.js. Busca quelcom com:
import { getReport, ... } from '../api/report'
Afegeix getReportDownloadUrl a la llista d'imports d'aquell fitxer. Si no hi ha imports de report.js, afegeix:
import { getReportDownloadUrl } from '../api/report'
- Step 2: Afegir l'estat reactiu del menú
Localitza la línia const isComplete = ref(false) (línia ~427) i afegeix just a sota:
const showDownloadMenu = ref(false)
- Step 3: Afegir la funció de tancament del menú al clicar fora
Localitza la funció goToInteraction (línia ~410) i afegeix just a sobre:
const closeDownloadMenu = () => { showDownloadMenu.value = false }
- Step 4: Afegir el botó desplegable al template
Localitza el bloc del botó "next step" al template (línia ~130):
<!-- Next Step Button - 在完成后显示 -->
<button v-if="isComplete" class="next-step-btn" @click="goToInteraction">
Afegeix el botó desplegable de descàrrega just abans d'aquest bloc:
<!-- Download Button - apareix quan el report és complet -->
<div v-if="isComplete" class="download-wrapper" v-click-outside="closeDownloadMenu">
<button class="download-toggle-btn" @click="showDownloadMenu = !showDownloadMenu">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>{{ $t('step4.downloadReport') }}</span>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="chevron-icon" :class="{ open: showDownloadMenu }">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div v-if="showDownloadMenu" class="download-menu">
<a :href="getReportDownloadUrl(reportId, 'md')" download class="download-option" @click="showDownloadMenu = false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span>Markdown (.md)</span>
</a>
<a :href="getReportDownloadUrl(reportId, 'pdf')" download class="download-option" @click="showDownloadMenu = false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span>PDF (.pdf)</span>
</a>
</div>
</div>
Nota: Vue 3 no té v-click-outside built-in. Si el projecte no té aquesta directiva, substitueix v-click-outside="closeDownloadMenu" per res i afegeix al <script setup> un event listener:
// Tanca el menú si es clica fora
import { onMounted, onUnmounted } from 'vue'
const handleClickOutside = (e) => {
if (!e.target.closest('.download-wrapper')) showDownloadMenu.value = false
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
- Step 5: Afegir les claus i18n
A locales/en.json, dins l'objecte step4, afegeix:
"downloadReport": "Download Report"
A locales/zh.json, dins l'objecte step4, afegeix:
"downloadReport": "下载报告"
- Step 6: Afegir estils CSS
Al final del bloc <style> de Step4Report.vue (just abans del tancament </style>), afegeix:
/* Download dropdown */
.download-wrapper {
position: relative;
margin: 8px 20px 0 20px;
}
.download-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 11px 20px;
font-size: 13px;
font-weight: 500;
color: #D1D5DB;
background: #111827;
border: 1px solid #374151;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.download-toggle-btn:hover {
background: #1F2937;
color: #F9FAFB;
}
.chevron-icon {
transition: transform 0.2s ease;
margin-left: auto;
}
.chevron-icon.open {
transform: rotate(180deg);
}
.download-menu {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
right: 0;
background: #1F2937;
border: 1px solid #374151;
border-radius: 8px;
overflow: hidden;
z-index: 100;
box-shadow: 0 -4px 12px rgba(0,0,0,0.3);
}
.download-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 13px;
color: #D1D5DB;
text-decoration: none;
transition: background 0.15s ease;
}
.download-option:hover {
background: #374151;
color: #F9FAFB;
}
- Step 7: Verificar que el frontend compila
cd frontend && npm run build 2>&1 | tail -20
Resultat esperat: build sense errors.
- Step 8: Commit
git add frontend/src/components/Step4Report.vue locales/en.json locales/zh.json
git commit -m "feat(report): add MD/PDF download dropdown button"
Verificació end-to-end
- Inicia el servidor:
npm run dev(des de l'arrel del projecte) - Genera un report complet en qualsevol projecte existent
- Quan el report es completa, ha d'aparèixer el botó "Download Report" sobre el botó "Go to Interaction"
- Fes clic al botó: ha d'aparèixer un menú desplegable amb les opcions "Markdown (.md)" i "PDF (.pdf)"
- Fes clic a "Markdown (.md)": ha de descarregar-se un fitxer
.mdllegible - Fes clic a "PDF (.pdf)": ha de descarregar-se un fitxer
.pdfque s'obre correctament - Tanca el menú clicant fora: ha de tancar-se el desplegable
- Executa els tests del backend:
cd backend && uv run pytest tests/test_report_download.py -v