LiamKhoaLe commited on
Commit
53cf39f
·
1 Parent(s): efbe684

Upd PDF downloader + code snippet copier

Browse files
Files changed (6) hide show
  1. app.py +38 -8
  2. requirements.txt +3 -1
  3. static/script.js +191 -2
  4. static/styles.css +99 -0
  5. utils/__init__.py +1 -0
  6. utils/pdf.py +171 -0
app.py CHANGED
@@ -430,11 +430,11 @@ async def upload_files(
430
  preloaded_files = []
431
  for uf in files:
432
  raw = await uf.read()
433
- if len(raw) > max_mb * 1024 * 1024:
434
- raise HTTPException(400, detail=f"{uf.filename} exceeds {max_mb} MB limit")
435
- # Apply rename if present
436
- eff_name = rename_dict.get(uf.filename, uf.filename)
437
- preloaded_files.append((eff_name, raw))
438
 
439
  # Initialize job status
440
  app.state.jobs[job_id] = {
@@ -473,7 +473,7 @@ async def upload_files(
473
  cap = captioner.caption_image(im)
474
  caps.append(cap)
475
  except Exception as e:
476
- logger.warning(f"[{job_id}] Caption error in {fname}: {e}")
477
  captions.append(caps)
478
  else:
479
  captions = [[] for _ in pages]
@@ -713,6 +713,36 @@ async def generate_report(
713
  return ReportResponse(filename=eff_name, report_markdown=report_md, sources=sources_meta)
714
 
715
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  @app.post("/chat", response_model=ChatAnswerResponse)
717
  async def chat(
718
  user_id: str = Form(...),
@@ -775,7 +805,7 @@ async def _chat_impl(
775
  match = next((f["filename"] for f in files_ci if f.get("filename", "").lower() == fn.lower()), None)
776
  if match:
777
  doc = rag.get_file_summary(user_id=user_id, project_id=project_id, filename=match)
778
- if doc:
779
  return ChatAnswerResponse(
780
  answer=doc.get("summary", ""),
781
  sources=[{"filename": match, "file_summary": True}]
@@ -935,7 +965,7 @@ async def _chat_impl(
935
  fsum_map = {f["filename"]: f.get("summary","") for f in files_list}
936
  lines = [f"[{fn}] {fsum_map.get(fn, '')}" for fn in relevant_files]
937
  file_summary_block = "\n".join(lines)
938
-
939
  # Guardrail instruction to avoid hallucination
940
  system_prompt = (
941
  "You are a careful study assistant. Answer strictly using the given CONTEXT.\n"
 
430
  preloaded_files = []
431
  for uf in files:
432
  raw = await uf.read()
433
+ if len(raw) > max_mb * 1024 * 1024:
434
+ raise HTTPException(400, detail=f"{uf.filename} exceeds {max_mb} MB limit")
435
+ # Apply rename if present
436
+ eff_name = rename_dict.get(uf.filename, uf.filename)
437
+ preloaded_files.append((eff_name, raw))
438
 
439
  # Initialize job status
440
  app.state.jobs[job_id] = {
 
473
  cap = captioner.caption_image(im)
474
  caps.append(cap)
475
  except Exception as e:
476
+ logger.warning(f"[{job_id}] Caption error in {fname}: {e}")
477
  captions.append(caps)
478
  else:
479
  captions = [[] for _ in pages]
 
713
  return ReportResponse(filename=eff_name, report_markdown=report_md, sources=sources_meta)
714
 
715
 
716
+ @app.post("/report/pdf")
717
+ async def generate_report_pdf(
718
+ user_id: str = Form(...),
719
+ project_id: str = Form(...),
720
+ report_content: str = Form(...)
721
+ ):
722
+ """
723
+ Generate a PDF from report content using the PDF utility module
724
+ """
725
+ from utils.pdf import generate_report_pdf as generate_pdf
726
+ from fastapi.responses import Response
727
+
728
+ try:
729
+ pdf_content = await generate_pdf(report_content, user_id, project_id)
730
+
731
+ # Return PDF as response
732
+ return Response(
733
+ content=pdf_content,
734
+ media_type="application/pdf",
735
+ headers={"Content-Disposition": f"attachment; filename=report-{datetime.now().strftime('%Y-%m-%d')}.pdf"}
736
+ )
737
+
738
+ except HTTPException:
739
+ # Re-raise HTTP exceptions as-is
740
+ raise
741
+ except Exception as e:
742
+ logger.error(f"[PDF] Unexpected error in PDF endpoint: {e}")
743
+ raise HTTPException(500, detail=f"Failed to generate PDF: {str(e)}")
744
+
745
+
746
  @app.post("/chat", response_model=ChatAnswerResponse)
747
  async def chat(
748
  user_id: str = Form(...),
 
805
  match = next((f["filename"] for f in files_ci if f.get("filename", "").lower() == fn.lower()), None)
806
  if match:
807
  doc = rag.get_file_summary(user_id=user_id, project_id=project_id, filename=match)
808
+ if doc:
809
  return ChatAnswerResponse(
810
  answer=doc.get("summary", ""),
811
  sources=[{"filename": match, "file_summary": True}]
 
965
  fsum_map = {f["filename"]: f.get("summary","") for f in files_list}
966
  lines = [f"[{fn}] {fsum_map.get(fn, '')}" for fn in relevant_files]
967
  file_summary_block = "\n".join(lines)
968
+
969
  # Guardrail instruction to avoid hallucination
970
  system_prompt = (
971
  "You are a careful study assistant. Answer strictly using the given CONTEXT.\n"
requirements.txt CHANGED
@@ -10,4 +10,6 @@ transformers==4.44.2
10
  torch==2.2.2
11
  sentence-transformers==3.1.1
12
  sumy==0.11.0
13
- numpy==1.26.4
 
 
 
10
  torch==2.2.2
11
  sentence-transformers==3.1.1
12
  sumy==0.11.0
13
+ numpy==1.26.4
14
+ weasyprint==62.3
15
+ markdown==3.6
static/script.js CHANGED
@@ -514,7 +514,7 @@
514
  const data = await response.json();
515
  if (response.ok) {
516
  thinkingMsg.remove();
517
- appendMessage('assistant', data.report_markdown || 'No report');
518
  if (data.sources && data.sources.length) appendSources(data.sources);
519
  // Save assistant report to chat history for persistence
520
  try { await saveChatMessage(user.user_id, currentProject.project_id, 'assistant', data.report_markdown || 'No report'); } catch {}
@@ -656,7 +656,7 @@
656
  }
657
  }
658
 
659
- function appendMessage(role, text) {
660
  const messageDiv = document.createElement('div');
661
  messageDiv.className = `msg ${role}`;
662
 
@@ -666,6 +666,14 @@
666
  // Use marked library to convert Markdown to HTML
667
  const htmlContent = marked.parse(text);
668
  messageDiv.innerHTML = htmlContent;
 
 
 
 
 
 
 
 
669
  } catch (e) {
670
  // Fallback to plain text if Markdown parsing fails
671
  messageDiv.textContent = text;
@@ -683,6 +691,187 @@
683
 
684
  return messageDiv;
685
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
686
 
687
  function appendSources(sources) {
688
  const sourcesDiv = document.createElement('div');
 
514
  const data = await response.json();
515
  if (response.ok) {
516
  thinkingMsg.remove();
517
+ appendMessage('assistant', data.report_markdown || 'No report', true); // isReport = true
518
  if (data.sources && data.sources.length) appendSources(data.sources);
519
  // Save assistant report to chat history for persistence
520
  try { await saveChatMessage(user.user_id, currentProject.project_id, 'assistant', data.report_markdown || 'No report'); } catch {}
 
656
  }
657
  }
658
 
659
+ function appendMessage(role, text, isReport = false) {
660
  const messageDiv = document.createElement('div');
661
  messageDiv.className = `msg ${role}`;
662
 
 
666
  // Use marked library to convert Markdown to HTML
667
  const htmlContent = marked.parse(text);
668
  messageDiv.innerHTML = htmlContent;
669
+
670
+ // Add copy buttons to code blocks
671
+ addCopyButtonsToCodeBlocks(messageDiv);
672
+
673
+ // Add download PDF button for reports
674
+ if (isReport) {
675
+ addDownloadPdfButton(messageDiv, text);
676
+ }
677
  } catch (e) {
678
  // Fallback to plain text if Markdown parsing fails
679
  messageDiv.textContent = text;
 
691
 
692
  return messageDiv;
693
  }
694
+
695
+ function addCopyButtonsToCodeBlocks(messageDiv) {
696
+ const codeBlocks = messageDiv.querySelectorAll('pre code');
697
+ codeBlocks.forEach((codeBlock, index) => {
698
+ const pre = codeBlock.parentElement;
699
+ const language = codeBlock.className.match(/language-(\w+)/)?.[1] || 'code';
700
+
701
+ // Create wrapper
702
+ const wrapper = document.createElement('div');
703
+ wrapper.className = 'code-block-wrapper';
704
+
705
+ // Create header with language and copy button
706
+ const header = document.createElement('div');
707
+ header.className = 'code-block-header';
708
+ header.innerHTML = `
709
+ <span class="code-block-language">${language}</span>
710
+ <button class="copy-code-btn" data-code-index="${index}">
711
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
712
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
713
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
714
+ </svg>
715
+ Copy
716
+ </button>
717
+ `;
718
+
719
+ // Create content wrapper
720
+ const content = document.createElement('div');
721
+ content.className = 'code-block-content';
722
+ content.appendChild(codeBlock.cloneNode(true));
723
+
724
+ // Assemble wrapper
725
+ wrapper.appendChild(header);
726
+ wrapper.appendChild(content);
727
+
728
+ // Replace original pre with wrapper
729
+ pre.parentNode.replaceChild(wrapper, pre);
730
+
731
+ // Add click handler for copy button
732
+ const copyBtn = wrapper.querySelector('.copy-code-btn');
733
+ copyBtn.addEventListener('click', () => copyCodeToClipboard(codeBlock.textContent, copyBtn));
734
+ });
735
+ }
736
+
737
+ function copyCodeToClipboard(code, button) {
738
+ navigator.clipboard.writeText(code).then(() => {
739
+ const originalText = button.innerHTML;
740
+ button.innerHTML = `
741
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
742
+ <polyline points="20,6 9,17 4,12"></polyline>
743
+ </svg>
744
+ Copied!
745
+ `;
746
+ button.classList.add('copied');
747
+
748
+ setTimeout(() => {
749
+ button.innerHTML = originalText;
750
+ button.classList.remove('copied');
751
+ }, 2000);
752
+ }).catch(err => {
753
+ console.error('Failed to copy code:', err);
754
+ // Fallback for older browsers
755
+ const textArea = document.createElement('textarea');
756
+ textArea.value = code;
757
+ document.body.appendChild(textArea);
758
+ textArea.select();
759
+ try {
760
+ document.execCommand('copy');
761
+ button.innerHTML = `
762
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
763
+ <polyline points="20,6 9,17 4,12"></polyline>
764
+ </svg>
765
+ Copied!
766
+ `;
767
+ button.classList.add('copied');
768
+ setTimeout(() => {
769
+ button.innerHTML = `
770
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
771
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
772
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
773
+ </svg>
774
+ Copy
775
+ `;
776
+ button.classList.remove('copied');
777
+ }, 2000);
778
+ } catch (fallbackErr) {
779
+ console.error('Fallback copy failed:', fallbackErr);
780
+ }
781
+ document.body.removeChild(textArea);
782
+ });
783
+ }
784
+
785
+ function addDownloadPdfButton(messageDiv, reportContent) {
786
+ const downloadBtn = document.createElement('button');
787
+ downloadBtn.className = 'download-pdf-btn';
788
+ downloadBtn.innerHTML = `
789
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
790
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
791
+ <polyline points="7,10 12,15 17,10"></polyline>
792
+ <line x1="12" y1="15" x2="12" y2="3"></line>
793
+ </svg>
794
+ Download PDF
795
+ `;
796
+
797
+ downloadBtn.addEventListener('click', () => downloadReportAsPdf(reportContent, downloadBtn));
798
+ messageDiv.appendChild(downloadBtn);
799
+ }
800
+
801
+ async function downloadReportAsPdf(reportContent, button) {
802
+ const user = window.__sb_get_user();
803
+ const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
804
+
805
+ if (!user || !currentProject) {
806
+ alert('Please sign in and select a project');
807
+ return;
808
+ }
809
+
810
+ button.disabled = true;
811
+ button.innerHTML = `
812
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
813
+ <circle cx="12" cy="12" r="10"></circle>
814
+ <polyline points="12,6 12,12 16,14"></polyline>
815
+ </svg>
816
+ Generating PDF...
817
+ `;
818
+
819
+ try {
820
+ const formData = new FormData();
821
+ formData.append('user_id', user.user_id);
822
+ formData.append('project_id', currentProject.project_id);
823
+ formData.append('report_content', reportContent);
824
+
825
+ const response = await fetch('/report/pdf', {
826
+ method: 'POST',
827
+ body: formData
828
+ });
829
+
830
+ if (response.ok) {
831
+ const blob = await response.blob();
832
+ const url = window.URL.createObjectURL(blob);
833
+ const a = document.createElement('a');
834
+ a.href = url;
835
+ a.download = `report-${new Date().toISOString().split('T')[0]}.pdf`;
836
+ document.body.appendChild(a);
837
+ a.click();
838
+ window.URL.revokeObjectURL(url);
839
+ document.body.removeChild(a);
840
+
841
+ button.innerHTML = `
842
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
843
+ <polyline points="20,6 9,17 4,12"></polyline>
844
+ </svg>
845
+ Downloaded!
846
+ `;
847
+ setTimeout(() => {
848
+ button.innerHTML = `
849
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
850
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
851
+ <polyline points="7,10 12,15 17,10"></polyline>
852
+ <line x1="12" y1="15" x2="12" y2="3"></line>
853
+ </svg>
854
+ Download PDF
855
+ `;
856
+ button.disabled = false;
857
+ }, 2000);
858
+ } else {
859
+ throw new Error('Failed to generate PDF');
860
+ }
861
+ } catch (error) {
862
+ console.error('PDF generation failed:', error);
863
+ alert('Failed to generate PDF. Please try again.');
864
+ button.innerHTML = `
865
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
866
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
867
+ <polyline points="7,10 12,15 17,10"></polyline>
868
+ <line x1="12" y1="15" x2="12" y2="3"></line>
869
+ </svg>
870
+ Download PDF
871
+ `;
872
+ button.disabled = false;
873
+ }
874
+ }
875
 
876
  function appendSources(sources) {
877
  const sourcesDiv = document.createElement('div');
static/styles.css CHANGED
@@ -1018,6 +1018,105 @@
1018
  overflow: auto;
1019
  }
1020
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1021
  /* Modal */
1022
  .modal {
1023
  position: fixed;
 
1018
  overflow: auto;
1019
  }
1020
 
1021
+ /* Code blocks with copy button */
1022
+ .code-block-wrapper {
1023
+ position: relative;
1024
+ margin: 16px 0;
1025
+ border-radius: var(--radius);
1026
+ overflow: hidden;
1027
+ border: 1px solid var(--border);
1028
+ }
1029
+
1030
+ .code-block-header {
1031
+ display: flex;
1032
+ justify-content: space-between;
1033
+ align-items: center;
1034
+ padding: 8px 12px;
1035
+ background: var(--bg-secondary);
1036
+ border-bottom: 1px solid var(--border);
1037
+ font-size: 12px;
1038
+ color: var(--muted);
1039
+ }
1040
+
1041
+ .code-block-language {
1042
+ font-weight: 600;
1043
+ text-transform: uppercase;
1044
+ }
1045
+
1046
+ .copy-code-btn {
1047
+ display: flex;
1048
+ align-items: center;
1049
+ gap: 4px;
1050
+ padding: 4px 8px;
1051
+ background: var(--card);
1052
+ border: 1px solid var(--border);
1053
+ border-radius: var(--radius-sm);
1054
+ color: var(--text-secondary);
1055
+ font-size: 11px;
1056
+ cursor: pointer;
1057
+ transition: all 0.2s ease;
1058
+ }
1059
+
1060
+ .copy-code-btn:hover {
1061
+ background: var(--card-hover);
1062
+ border-color: var(--border-light);
1063
+ color: var(--text);
1064
+ }
1065
+
1066
+ .copy-code-btn.copied {
1067
+ background: var(--success);
1068
+ color: white;
1069
+ border-color: var(--success);
1070
+ }
1071
+
1072
+ .copy-code-btn svg {
1073
+ width: 12px;
1074
+ height: 12px;
1075
+ }
1076
+
1077
+ .code-block-content {
1078
+ background: var(--code-bg, #1e1e1e);
1079
+ color: var(--code-text, #d4d4d4);
1080
+ padding: 16px;
1081
+ overflow-x: auto;
1082
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1083
+ font-size: 14px;
1084
+ line-height: 1.5;
1085
+ }
1086
+
1087
+ /* Download PDF button */
1088
+ .download-pdf-btn {
1089
+ display: inline-flex;
1090
+ align-items: center;
1091
+ gap: 8px;
1092
+ margin-top: 16px;
1093
+ padding: 12px 20px;
1094
+ background: var(--gradient-accent);
1095
+ color: white;
1096
+ border: none;
1097
+ border-radius: var(--radius);
1098
+ font-weight: 600;
1099
+ cursor: pointer;
1100
+ transition: all 0.2s ease;
1101
+ box-shadow: var(--shadow);
1102
+ }
1103
+
1104
+ .download-pdf-btn:hover {
1105
+ transform: translateY(-1px);
1106
+ box-shadow: var(--shadow-lg);
1107
+ }
1108
+
1109
+ .download-pdf-btn:disabled {
1110
+ opacity: 0.6;
1111
+ cursor: not-allowed;
1112
+ transform: none;
1113
+ }
1114
+
1115
+ .download-pdf-btn svg {
1116
+ width: 16px;
1117
+ height: 16px;
1118
+ }
1119
+
1120
  /* Modal */
1121
  .modal {
1122
  position: fixed;
utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Utils package for StudyBuddy
utils/pdf.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF generation utilities for StudyBuddy
3
+ """
4
+ import os
5
+ import tempfile
6
+ import markdown
7
+ from datetime import datetime
8
+ from fastapi import HTTPException
9
+ from utils.logger import get_logger
10
+
11
+ logger = get_logger("PDF", __name__)
12
+
13
+
14
+ async def generate_report_pdf(report_content: str, user_id: str, project_id: str) -> bytes:
15
+ """
16
+ Generate a PDF from report content using weasyprint
17
+
18
+ Args:
19
+ report_content: Markdown content of the report
20
+ user_id: User ID for logging
21
+ project_id: Project ID for logging
22
+
23
+ Returns:
24
+ PDF content as bytes
25
+
26
+ Raises:
27
+ HTTPException: If PDF generation fails
28
+ """
29
+ try:
30
+ from weasyprint import HTML, CSS
31
+ from weasyprint.text.fonts import FontConfiguration
32
+
33
+ logger.info(f"[PDF] Generating PDF for user {user_id}, project {project_id}")
34
+
35
+ # Convert markdown to HTML
36
+ html_content = markdown.markdown(report_content, extensions=['codehilite', 'fenced_code'])
37
+
38
+ # Create a complete HTML document with styling
39
+ full_html = f"""
40
+ <!DOCTYPE html>
41
+ <html>
42
+ <head>
43
+ <meta charset="utf-8">
44
+ <title>Study Report</title>
45
+ <style>
46
+ body {{
47
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
48
+ line-height: 1.6;
49
+ color: #333;
50
+ max-width: 800px;
51
+ margin: 0 auto;
52
+ padding: 40px 20px;
53
+ }}
54
+ h1, h2, h3, h4, h5, h6 {{
55
+ color: #2c3e50;
56
+ margin-top: 2em;
57
+ margin-bottom: 1em;
58
+ }}
59
+ h1 {{
60
+ border-bottom: 2px solid #3498db;
61
+ padding-bottom: 10px;
62
+ }}
63
+ h2 {{
64
+ border-bottom: 1px solid #bdc3c7;
65
+ padding-bottom: 5px;
66
+ }}
67
+ code {{
68
+ background: #f8f9fa;
69
+ padding: 2px 6px;
70
+ border-radius: 3px;
71
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
72
+ font-size: 0.9em;
73
+ }}
74
+ pre {{
75
+ background: #2c3e50;
76
+ color: #ecf0f1;
77
+ padding: 20px;
78
+ border-radius: 8px;
79
+ overflow-x: auto;
80
+ margin: 1em 0;
81
+ }}
82
+ pre code {{
83
+ background: none;
84
+ padding: 0;
85
+ color: inherit;
86
+ }}
87
+ blockquote {{
88
+ border-left: 4px solid #3498db;
89
+ margin: 1em 0;
90
+ padding-left: 20px;
91
+ color: #7f8c8d;
92
+ }}
93
+ table {{
94
+ border-collapse: collapse;
95
+ width: 100%;
96
+ margin: 1em 0;
97
+ }}
98
+ th, td {{
99
+ border: 1px solid #bdc3c7;
100
+ padding: 12px;
101
+ text-align: left;
102
+ }}
103
+ th {{
104
+ background: #ecf0f1;
105
+ font-weight: 600;
106
+ }}
107
+ ul, ol {{
108
+ padding-left: 2em;
109
+ }}
110
+ li {{
111
+ margin: 0.5em 0;
112
+ }}
113
+ p {{
114
+ margin: 1em 0;
115
+ }}
116
+ .page-break {{
117
+ page-break-before: always;
118
+ }}
119
+ @page {{
120
+ margin: 2cm;
121
+ @bottom-center {{
122
+ content: "Page " counter(page);
123
+ font-size: 10px;
124
+ color: #7f8c8d;
125
+ }}
126
+ }}
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <h1>Study Report</h1>
131
+ <p><em>Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')}</em></p>
132
+ <hr>
133
+ {html_content}
134
+ </body>
135
+ </html>
136
+ """
137
+
138
+ # Generate PDF
139
+ font_config = FontConfiguration()
140
+ html_doc = HTML(string=full_html)
141
+ css = CSS(string='', font_config=font_config)
142
+
143
+ # Create temporary file for PDF
144
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file:
145
+ pdf_path = tmp_file.name
146
+
147
+ try:
148
+ html_doc.write_pdf(pdf_path, stylesheets=[css], font_config=font_config)
149
+
150
+ # Read the generated PDF
151
+ with open(pdf_path, 'rb') as pdf_file:
152
+ pdf_content = pdf_file.read()
153
+
154
+ # Clean up temporary file
155
+ os.unlink(pdf_path)
156
+
157
+ logger.info(f"[PDF] Successfully generated PDF ({len(pdf_content)} bytes) for user {user_id}, project {project_id}")
158
+ return pdf_content
159
+
160
+ except Exception as e:
161
+ # Clean up temporary file on error
162
+ if os.path.exists(pdf_path):
163
+ os.unlink(pdf_path)
164
+ raise e
165
+
166
+ except ImportError:
167
+ logger.error("[PDF] weasyprint not installed. Install with: pip install weasyprint")
168
+ raise HTTPException(500, detail="PDF generation not available. Please install weasyprint.")
169
+ except Exception as e:
170
+ logger.error(f"[PDF] Failed to generate PDF: {e}")
171
+ raise HTTPException(500, detail=f"Failed to generate PDF: {str(e)}")