-
-
Notifications
You must be signed in to change notification settings - Fork 6
Implement PDF Report Generation #602
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1ca140d
5517ab6
e1b6ff0
e0cda69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| 'use client' | ||
|
|
||
| import React, { useState } from 'react' | ||
| import { Button } from '@/components/ui/button' | ||
| import { FileDown, Loader2 } from 'lucide-react' | ||
| import { useAIState } from 'ai/rsc' | ||
| import { useMapData } from '@/components/map/map-data-context' | ||
| import { useMap } from '@/components/map/map-context' | ||
| import { generateReport } from '@/lib/utils/report-generator' | ||
| import { toast } from 'sonner' | ||
|
|
||
| interface ReportButtonProps { | ||
| inline?: boolean | ||
| } | ||
|
|
||
| export const ReportButton = ({ inline = false }: ReportButtonProps) => { | ||
| const [aiState] = useAIState() | ||
| const { mapData } = useMapData() | ||
| const { map } = useMap() | ||
| const [isGenerating, setIsGenerating] = useState(false) | ||
|
|
||
| const handleDownloadReport = async () => { | ||
| if (isGenerating) return | ||
|
|
||
| setIsGenerating(true) | ||
| try { | ||
| const mapSnapshot = map ? map.getCanvas().toDataURL('image/png') : '' | ||
|
|
||
| const chatTitle = aiState.chatId ? `Chat-${aiState.chatId.substring(0, 8)}` : 'QCX-Analysis' | ||
|
|
||
| await generateReport({ | ||
| messages: aiState.messages, | ||
| drawnFeatures: mapData.drawnFeatures || [], | ||
| mapSnapshot, | ||
| chatTitle | ||
| }) | ||
|
|
||
| // Removed success toast as per user request | ||
| } catch (error) { | ||
| console.error('Failed to generate report:', error) | ||
| toast.error('Failed to generate report') | ||
| } finally { | ||
| setIsGenerating(false) | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <Button | ||
| variant={inline ? "default" : "ghost"} | ||
| size={inline ? "default" : "icon"} | ||
| onClick={handleDownloadReport} | ||
| title="Download PDF Report" | ||
| disabled={isGenerating} | ||
| className={inline ? "w-full" : ""} | ||
| > | ||
| {isGenerating ? ( | ||
| <Loader2 className="h-[1.2rem] w-[1.2rem] animate-spin" /> | ||
| ) : ( | ||
| <FileDown className="h-[1.2rem] w-[1.2rem]" /> | ||
| )} | ||
| {inline && <span className="ml-2">Generate Report</span>} | ||
| </Button> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,169 @@ | ||||||||||||||||
| import { jsPDF } from 'jspdf'; | ||||||||||||||||
| import { AIMessage } from '@/lib/types'; | ||||||||||||||||
| import { MapData } from '@/components/map/map-data-context'; | ||||||||||||||||
| import markdownToTxt from 'markdown-to-txt'; | ||||||||||||||||
|
|
||||||||||||||||
| export interface ReportData { | ||||||||||||||||
| messages: AIMessage[]; | ||||||||||||||||
| drawnFeatures: MapData['drawnFeatures']; | ||||||||||||||||
| mapSnapshot: string; | ||||||||||||||||
| chatTitle: string; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export async function generateReport({ | ||||||||||||||||
| messages, | ||||||||||||||||
| drawnFeatures, | ||||||||||||||||
| mapSnapshot, | ||||||||||||||||
| chatTitle | ||||||||||||||||
| }: ReportData) { | ||||||||||||||||
| const doc = new jsPDF(); | ||||||||||||||||
| const pageWidth = doc.internal.pageSize.getWidth(); | ||||||||||||||||
| const pageHeight = doc.internal.pageSize.getHeight(); | ||||||||||||||||
| const margin = 15; | ||||||||||||||||
| const contentWidth = pageWidth - 2 * margin; | ||||||||||||||||
| let yOffset = margin; | ||||||||||||||||
|
|
||||||||||||||||
| const checkPageBreak = (neededHeight: number) => { | ||||||||||||||||
| if (yOffset + neededHeight > pageHeight - margin) { | ||||||||||||||||
| doc.addPage(); | ||||||||||||||||
| yOffset = margin; | ||||||||||||||||
| return true; | ||||||||||||||||
| } | ||||||||||||||||
| return false; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| const addTextWithAutoPageBreak = (text: string, fontSize: number, style: 'normal' | 'bold' = 'normal', color: [number, number, number] = [0, 0, 0]) => { | ||||||||||||||||
| doc.setFontSize(fontSize); | ||||||||||||||||
| doc.setFont('helvetica', style); | ||||||||||||||||
| doc.setTextColor(color[0], color[1], color[2]); | ||||||||||||||||
|
|
||||||||||||||||
| const lines: string[] = doc.splitTextToSize(text, contentWidth); | ||||||||||||||||
| for (const line of lines) { | ||||||||||||||||
| if (yOffset + 7 > pageHeight - margin) { | ||||||||||||||||
| doc.addPage(); | ||||||||||||||||
| yOffset = margin; | ||||||||||||||||
| } | ||||||||||||||||
| doc.text(line, margin, yOffset); | ||||||||||||||||
| yOffset += 7; | ||||||||||||||||
| } | ||||||||||||||||
| yOffset += 3; // Small gap after text blocks | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| // --- Cover Page --- | ||||||||||||||||
| doc.setFontSize(24); | ||||||||||||||||
| doc.setTextColor(40, 40, 40); | ||||||||||||||||
| doc.text('QCX Analysis Report', margin, yOffset); | ||||||||||||||||
| yOffset += 15; | ||||||||||||||||
|
|
||||||||||||||||
| doc.setFontSize(18); | ||||||||||||||||
| doc.text(chatTitle || 'Untitled Chat', margin, yOffset); | ||||||||||||||||
| yOffset += 10; | ||||||||||||||||
|
|
||||||||||||||||
| doc.setFontSize(12); | ||||||||||||||||
| doc.setTextColor(100, 100, 100); | ||||||||||||||||
| doc.text(`Generated on: ${new Date().toLocaleString()}`, margin, yOffset); | ||||||||||||||||
| yOffset += 20; | ||||||||||||||||
|
|
||||||||||||||||
| if (mapSnapshot) { | ||||||||||||||||
| try { | ||||||||||||||||
| const imgHeight = (contentWidth * 9) / 16; | ||||||||||||||||
| checkPageBreak(imgHeight); | ||||||||||||||||
| doc.addImage(mapSnapshot, 'PNG', margin, yOffset, contentWidth, imgHeight); | ||||||||||||||||
| yOffset += imgHeight + 20; | ||||||||||||||||
| } catch (e) { | ||||||||||||||||
| console.error('Error adding map snapshot to PDF:', e); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // --- Q&A Section --- | ||||||||||||||||
| doc.setFontSize(16); | ||||||||||||||||
| doc.setTextColor(40, 40, 40); | ||||||||||||||||
| checkPageBreak(10); | ||||||||||||||||
| doc.text('Conversation History', margin, yOffset); | ||||||||||||||||
| yOffset += 10; | ||||||||||||||||
|
|
||||||||||||||||
| const userMessages = messages.filter(m => m.type === 'input' || m.type === 'input_related'); | ||||||||||||||||
|
|
||||||||||||||||
| for (const userMsg of userMessages) { | ||||||||||||||||
| let userContent = ''; | ||||||||||||||||
| try { | ||||||||||||||||
| const json = JSON.parse(userMsg.content as string); | ||||||||||||||||
| userContent = userMsg.type === 'input' ? json.input : json.related_query; | ||||||||||||||||
| } catch (e) { | ||||||||||||||||
| userContent = userMsg.content as string; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| addTextWithAutoPageBreak(`User: ${userContent}`, 12, 'bold', [60, 60, 60]); | ||||||||||||||||
|
|
||||||||||||||||
| const userIdx = messages.indexOf(userMsg); | ||||||||||||||||
| const nextUserIdx = messages.findIndex((m, i) => i > userIdx && (m.type === 'input' || m.type === 'input_related')); | ||||||||||||||||
| const turnMessages = messages.slice(userIdx + 1, nextUserIdx === -1 ? undefined : nextUserIdx); | ||||||||||||||||
|
|
||||||||||||||||
| const aiResponse = turnMessages.find(m => m.type === 'response'); | ||||||||||||||||
| if (aiResponse) { | ||||||||||||||||
| // Render markdown as plain text | ||||||||||||||||
| const plainTextAI = markdownToTxt(aiResponse.content as string); | ||||||||||||||||
| addTextWithAutoPageBreak(`QCX: ${plainTextAI}`, 12, 'normal', [80, 80, 80]); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const searchResult = turnMessages.find(m => m.type === 'resolution_search_result'); | ||||||||||||||||
| if (searchResult) { | ||||||||||||||||
| try { | ||||||||||||||||
| const data = JSON.parse(searchResult.content as string); | ||||||||||||||||
|
|
||||||||||||||||
| // GeoJSON Summary | ||||||||||||||||
| if (data.summary) { | ||||||||||||||||
| addTextWithAutoPageBreak(`Analysis Summary: ${data.summary}`, 11, 'normal', [80, 80, 80]); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const images = [data.mapboxImage, data.googleImage, data.image].filter(Boolean); | ||||||||||||||||
| if (images.length > 0) { | ||||||||||||||||
| const imgWidth = (contentWidth - 10) / 2; | ||||||||||||||||
| const imgHeight = (imgWidth * 3) / 4; | ||||||||||||||||
|
|
||||||||||||||||
| checkPageBreak(imgHeight + 10); | ||||||||||||||||
|
|
||||||||||||||||
| for (let i = 0; i < Math.min(images.length, 2); i++) { | ||||||||||||||||
| doc.addImage(images[i], 'JPEG', margin + (i * (imgWidth + 10)), yOffset, imgWidth, imgHeight); | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verify image format before hardcoding 'JPEG'. The code assumes all images are JPEG format, but the images from different sources ( Consider detecting the format from the data URL prefix or use a more flexible format like 'PNG' that supports transparency, or handle each image's actual format: 🔍 Suggested improvement for (let i = 0; i < Math.min(images.length, 2); i++) {
- doc.addImage(images[i], 'JPEG', margin + (i * (imgWidth + 10)), yOffset, imgWidth, imgHeight);
+ // Detect format from data URL or default to JPEG
+ const format = images[i].startsWith('data:image/png') ? 'PNG' : 'JPEG';
+ doc.addImage(images[i], format, margin + (i * (imgWidth + 10)), yOffset, imgWidth, imgHeight);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| } | ||||||||||||||||
| yOffset += imgHeight + 10; | ||||||||||||||||
| } | ||||||||||||||||
| } catch (e) { | ||||||||||||||||
| console.error('Error parsing resolution search result for PDF:', e); | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+112
to
+133
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate parsed search result structure before accessing properties. The code accesses 🛡️ Suggested fix with proper validation try {
const data = JSON.parse(searchResult.content as string);
// GeoJSON Summary
- if (data.summary) {
+ if (data && typeof data === 'object' && data.summary) {
addTextWithAutoPageBreak(`Analysis Summary: ${data.summary}`, 11, 'normal', [80, 80, 80]);
}
- const images = [data.mapboxImage, data.googleImage, data.image].filter(Boolean);
+ const images = data && typeof data === 'object'
+ ? [data.mapboxImage, data.googleImage, data.image].filter(Boolean)
+ : [];
if (images.length > 0) {🤖 Prompt for AI Agents |
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| yOffset += 5; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // --- Drawings Appendix --- | ||||||||||||||||
| if (drawnFeatures && drawnFeatures.length > 0) { | ||||||||||||||||
| doc.addPage(); | ||||||||||||||||
| yOffset = margin; | ||||||||||||||||
|
|
||||||||||||||||
| doc.setFontSize(16); | ||||||||||||||||
| doc.setTextColor(40, 40, 40); | ||||||||||||||||
| doc.setFont('helvetica', 'bold'); | ||||||||||||||||
| doc.text('Drawings & Measurements', margin, yOffset); | ||||||||||||||||
| yOffset += 15; | ||||||||||||||||
|
|
||||||||||||||||
| drawnFeatures.forEach((feature, index) => { | ||||||||||||||||
| checkPageBreak(25); | ||||||||||||||||
| doc.setFontSize(12); | ||||||||||||||||
| doc.setFont('helvetica', 'bold'); | ||||||||||||||||
| doc.text(`${index + 1}. ${feature.type}`, margin, yOffset); | ||||||||||||||||
| yOffset += 7; | ||||||||||||||||
|
|
||||||||||||||||
| doc.setFontSize(10); | ||||||||||||||||
| doc.setFont('helvetica', 'normal'); | ||||||||||||||||
| doc.text(`Measurement: ${feature.measurement}`, margin + 5, yOffset); | ||||||||||||||||
| yOffset += 7; | ||||||||||||||||
|
|
||||||||||||||||
| const coords = JSON.stringify(feature.geometry.coordinates).substring(0, 100) + '...'; | ||||||||||||||||
| doc.text(`Coordinates: ${coords}`, margin + 5, yOffset); | ||||||||||||||||
|
Comment on lines
+162
to
+163
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Improve coordinate truncation to avoid cutting mid-value. The current implementation uses ♻️ Suggested improvement for cleaner truncation- const coords = JSON.stringify(feature.geometry.coordinates).substring(0, 100) + '...';
- doc.text(`Coordinates: ${coords}`, margin + 5, yOffset);
+ // Show first few coordinate pairs cleanly
+ const coords = feature.geometry.coordinates;
+ const coordsArray = Array.isArray(coords[0]) ? coords.slice(0, 3) : [coords];
+ const coordsStr = JSON.stringify(coordsArray) + (coords.length > 3 ? '... (truncated)' : '');
+ doc.text(`Coordinates: ${coordsStr}`, margin + 5, yOffset);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| yOffset += 10; | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| doc.save(`${chatTitle || 'QCX-Report'}.pdf`); | ||||||||||||||||
| } | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,7 @@ | |
| "@tailwindcss/typography": "^0.5.16", | ||
| "@tavily/core": "^0.6.4", | ||
| "@turf/turf": "^7.2.0", | ||
| "@types/jspdf": "^2.0.0", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check jspdf package versions and compatibility
# Check latest available version of jspdf
echo "=== Latest jspdf version ==="
npm view jspdf version
# Check if version 4.2.1 exists
echo -e "\n=== Checking if jspdf@4.2.1 exists ==="
npm view jspdf@4.2.1 version 2>&1 || echo "Version 4.2.1 not found"
# Check available versions of jspdf
echo -e "\n=== Recent jspdf versions ==="
npm view jspdf versions --json | jq '.[-10:]'
# Check latest `@types/jspdf` version
echo -e "\n=== Latest `@types/jspdf` version ==="
npm view `@types/jspdf` version
# Check for security advisories
echo -e "\n=== Security advisories for jspdf ==="
npm audit --package-lock-only --json | jq '.vulnerabilities.jspdf // "No vulnerabilities found"'Repository: QueueLab/QCX Length of output: 757 Verify jsPDF 4.2.1 is the current stable version, not an outdated pre-release. However, 🤖 Prompt for AI Agents |
||
| "@types/mapbox__mapbox-gl-draw": "^1.4.8", | ||
| "@types/pg": "^8.15.4", | ||
| "@upstash/redis": "^1.35.0", | ||
|
|
@@ -68,11 +69,13 @@ | |
| "framer-motion": "^12.23.24", | ||
| "geotiff": "^2.1.4-beta.1", | ||
| "glassmorphic": "^0.0.3", | ||
| "jspdf": "^4.2.1", | ||
| "katex": "^0.16.22", | ||
| "lodash": "^4.17.21", | ||
| "lottie-react": "^2.4.1", | ||
| "lucide-react": "^0.507.0", | ||
| "mapbox-gl": "^3.11.0", | ||
| "markdown-to-txt": "^2.0.1", | ||
| "next": "15.3.8", | ||
| "next-themes": "^0.3.0", | ||
| "open-codex": "^0.1.30", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add validation for parsed JSON structure.
The parsed JSON object is not validated before accessing
json.inputorjson.related_query. If the structure is unexpected, this could fail silently or throw runtime errors.🛡️ Suggested fix with validation
let userContent = ''; try { const json = JSON.parse(userMsg.content as string); - userContent = userMsg.type === 'input' ? json.input : json.related_query; + userContent = userMsg.type === 'input' + ? (json.input || 'No input content') + : (json.related_query || 'No related query'); } catch (e) { userContent = userMsg.content as string; }📝 Committable suggestion
🤖 Prompt for AI Agents