Spaces:
Running
Running
| import os | |
| import markdown | |
| import datetime | |
| from bs4 import BeautifulSoup | |
| def export_to_docx( | |
| content: str, | |
| output_path: str, | |
| template: str = "standard", | |
| project_title: str = "Wniosek o Dofinansowanie", | |
| company_name: str = "Brak nazwy", | |
| version: str = "1.0", | |
| date_str: str = "", | |
| extra_context: dict = None, | |
| ): | |
| """ | |
| Eksportuje wygenerowany przez Wizarda wniosek do formatu Microsoft Word (DOCX). | |
| Wczytuje gotowy szablon docx z wymaganymi stalami i spisem treści. | |
| Integruje się z `docxtpl` by umożliwić elastyczne wstrzykiwanie zmiennych (np {{ beneficjent.krs }}). | |
| """ | |
| if extra_context is None: | |
| extra_context = {} | |
| try: | |
| from docxtpl import DocxTemplate | |
| template_name = ( | |
| template if template in ["standard", "official", "modern"] else "standard" | |
| ) | |
| template_path = os.path.join( | |
| os.path.dirname(__file__), | |
| "..", | |
| "templates", | |
| f"template_{template_name}.docx", | |
| ) | |
| if not os.path.exists(template_path): | |
| print(f"Brak pliku {template_path}, upewnij się, że wygenerowano szablony!") | |
| return False | |
| tpl = DocxTemplate(template_path) | |
| # Puste wartości dla formatowania markdown (usuwamy duplikujące title jeśli zaczyna się od H1) | |
| if content.startswith("# "): | |
| content = "\n".join(content.split("\n")[1:]) | |
| # Przygotowanie pełnego kontekstu dla DocxTemplate (Jinja2 tags) | |
| # Zostawiamy 'tresc_wniosku' puste, bo zastąpimy ten paragraf natywnym kodem python-docx | |
| render_context = { | |
| "tytul_projektu": project_title, | |
| "nazwa_firmy": company_name, | |
| "data_generowania": date_str, | |
| "wersja": version, | |
| "tresc_wniosku": "", | |
| } | |
| render_context.update(extra_context) | |
| tpl.render(render_context) | |
| # === Pełna integracja Świadectwa Zgodności w eksporcie (Cycle 4) === | |
| if extra_context and extra_context.get("include_grounding_certificate"): | |
| append_grounding_certificate_to_docx(tpl._document, { | |
| "title": project_title, | |
| "company_name": company_name, | |
| "version_hash": extra_context.get("version_hash"), | |
| "precise_regulation_url": extra_context.get("precise_regulation_url"), | |
| "regulation_link_quality": extra_context.get("regulation_link_quality"), | |
| "snapshot_data": extra_context.get("snapshot_data"), | |
| }) | |
| tpl.save(output_path) | |
| # 2. Otwieramy zapisany plik za pomocą natywnego python-docx | |
| import docx | |
| doc = docx.Document(output_path) | |
| # Usuwamy ostatni paragraf (który zawierał wyczyszczoną zmienną 'tresc_wniosku') | |
| if len(doc.paragraphs) > 0 and doc.paragraphs[-1].text.strip() == "": | |
| p = doc.paragraphs[-1] | |
| p._element.getparent().remove(p._element) | |
| # Konwersja MD do prostego HTML, a potem interpretacja BeautifulSoup | |
| html = markdown.markdown(content) | |
| soup = BeautifulSoup(html, "html.parser") | |
| for element in soup: | |
| if element.name in ["h1", "h2", "h3", "h4", "h5", "h6"]: | |
| level = int(element.name[1]) | |
| try: | |
| doc.add_paragraph(element.text, style=f"Heading {level}") | |
| except KeyError: | |
| doc.add_heading(element.text, level=level) | |
| elif element.name == "p": | |
| # Złożona obsługa bold/italic | |
| p = doc.add_paragraph() | |
| if template == "official": | |
| p.paragraph_format.alignment = 3 # Justify | |
| for child in element.children: | |
| if child.name is None: | |
| p.add_run(child.string) | |
| elif child.name in ["strong", "b"]: | |
| p.add_run(child.text).bold = True | |
| elif child.name in ["em", "i"]: | |
| p.add_run(child.text).italic = True | |
| else: | |
| p.add_run(child.text) | |
| elif element.name in ["ul", "ol"]: | |
| for li in element.find_all("li"): | |
| style_name = ( | |
| "List Bullet" if element.name == "ul" else "List Number" | |
| ) | |
| try: | |
| p = doc.add_paragraph(style=style_name) | |
| except KeyError: | |
| p = doc.add_paragraph(style="Normal") | |
| p.add_run("• ") | |
| for child in li.children: | |
| if child.name is None: | |
| p.add_run(child.string) | |
| elif child.name in ["strong", "b"]: | |
| p.add_run(child.text).bold = True | |
| elif child.name in ["em", "i"]: | |
| p.add_run(child.text).italic = True | |
| else: | |
| p.add_run(child.text) | |
| elif element.name == "table": | |
| # Ulepszona obsługa tabel dla DOCX | |
| rows = element.find_all("tr") | |
| if rows: | |
| cols = max(len(row.find_all(["th", "td"])) for row in rows) | |
| table = doc.add_table(rows=0, cols=cols) | |
| try: | |
| table.style = "Light Shading Accent 1" | |
| except KeyError: | |
| table.style = "Table Grid" | |
| for idx_row, tr in enumerate(rows): | |
| row = table.add_row() | |
| cells = tr.find_all(["th", "td"]) | |
| for idx, cell in enumerate(cells): | |
| if idx < cols: | |
| p = row.cells[idx].paragraphs[0] | |
| p.text = "" | |
| for child in cell.children: | |
| if child.name is None: | |
| p.add_run(child.string.strip() if child.string else "") | |
| elif child.name in ["strong", "b"]: | |
| p.add_run(child.text.strip()).bold = True | |
| elif child.name in ["em", "i"]: | |
| p.add_run(child.text.strip()).italic = True | |
| else: | |
| p.add_run(child.text.strip()) | |
| # Zawsze pogrubiamy nagłówki (<th> lub pierwszy wiersz) | |
| if cell.name == "th" or idx_row == 0: | |
| for run in p.runs: | |
| run.bold = True | |
| doc.save(output_path) | |
| return True | |
| except Exception: | |
| import traceback | |
| print(f"Błąd eksportu do DOCX: {traceback.format_exc()}") | |
| return False | |
| def append_grounding_certificate_to_docx(doc, project_data: dict): | |
| """Dodaje stronę Świadectwa Zgodności na końcu dokumentu DOCX.""" | |
| from docx.shared import Pt, RGBColor | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| doc.add_page_break() | |
| # Nagłówek świadectwa | |
| p = doc.add_paragraph() | |
| run = p.add_run("ŚWIADECTWO ZGODNOŚCI WERSJI / REGULATION GROUNDING CERTIFICATE") | |
| run.bold = True | |
| run.font.size = Pt(14) | |
| run.font.color.rgb = RGBColor(26, 54, 93) | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| p2 = doc.add_paragraph("GrantForge AI — Najwyższy poziom ugruntowania w regulaminach i prawie UE") | |
| p2.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| doc.add_paragraph() | |
| # Kluczowe dane | |
| cert_info = [ | |
| ("Projekt", project_data.get("title", "")), | |
| ("Beneficjent", project_data.get("company_name", "")), | |
| ("Data wygenerowania", datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")), | |
| ("Version Hash", project_data.get("version_hash", "N/A")), | |
| ("Precise Regulation URL", project_data.get("precise_regulation_url", "N/A")), | |
| ("Regulation Link Quality", project_data.get("regulation_link_quality", "N/A")), | |
| ] | |
| for label, value in cert_info: | |
| p = doc.add_paragraph() | |
| run = p.add_run(f"{label}: ") | |
| run.bold = True | |
| p.add_run(str(value)[:120]) | |
| # Snapshot summary jeśli dostępny | |
| if project_data.get("snapshot_data"): | |
| doc.add_paragraph() | |
| p = doc.add_paragraph() | |
| p.add_run("SNAPSHOT REGULAMINU (źródło prawdy)").bold = True | |
| for k, v in project_data["snapshot_data"].items(): | |
| if k in ["key_rules", "exclusions"]: | |
| continue | |
| doc.add_paragraph(f" {k}: {v}") | |
| doc.add_paragraph() | |
| p = doc.add_paragraph("Dokument ten potwierdza, że wszystkie wygenerowane treści opierają się na konkretnej, wersjonowanej wersji regulaminu oraz aktualnych aktach prawnych UE.") | |
| p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY | |
| p = doc.add_paragraph("https://grantforge.ai | Regulation Engine jako źródło prawdy") | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| def export_full_application_with_certificate( | |
| output_path: str, | |
| project_title: str, | |
| company_name: str, | |
| main_content: str, | |
| certificate_data: dict, | |
| ) -> bool: | |
| """Eksportuje pełny wniosek + stronę Świadectwa Zgodności na końcu (PDF). Używane w cyklu finałowym.""" | |
| try: | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak | |
| from reportlab.lib.styles import getSampleStyleSheet | |
| doc = SimpleDocTemplate(output_path, pagesize=A4) | |
| styles = getSampleStyleSheet() | |
| story = [] | |
| # Główna treść wniosku (uproszczona) | |
| for line in (main_content or "").split("\n")[:80]: | |
| if line.strip(): | |
| story.append(Paragraph(line[:200], styles["Normal"])) | |
| story.append(PageBreak()) | |
| # Świadectwo | |
| cert = certificate_data or {} | |
| story.append(Paragraph("ŚWIADECTWO ZGODNOŚCI WERSJI / REGULATION GROUNDING CERTIFICATE", styles["Heading1"])) | |
| story.append(Spacer(1, 10)) | |
| story.append(Paragraph(f"Projekt: {project_title}", styles["Normal"])) | |
| story.append(Paragraph(f"Beneficjent: {company_name}", styles["Normal"])) | |
| story.append(Paragraph(f"Version Hash: {cert.get('version_hash', 'N/A')}", styles["Normal"])) | |
| story.append(Paragraph(f"Precise Regulation URL: {cert.get('precise_regulation_url', 'N/A')}", styles["Normal"])) | |
| story.append(Paragraph(f"Link Quality: {cert.get('regulation_link_quality', 'N/A')}", styles["Normal"])) | |
| doc.build(story) | |
| return True | |
| except Exception as e: | |
| print(f"Błąd eksportu PDF z certificate: {e}") | |
| return False | |
| def export_grounding_certificate_pdf( | |
| output_path: str, | |
| project_title: str, | |
| company_name: str, | |
| snapshot_data: dict, | |
| eurlex_links: list = None, | |
| pkd_list: list = None, | |
| msp_status: str = "", | |
| engine_checks: dict = None, | |
| version_hash: str = "", | |
| v5_certificate: dict = None, # v5.0 extended: citation_score, trap_risk, trust_score etc. | |
| ) -> bool: | |
| """ | |
| Generuje publiczne, profesjonalne Świadectwo Zgodności (Regulation Grounding Certificate) w formacie PDF. | |
| Zawiera wszystkie metadane snapshotu, hash, linki EUR-Lex, PKD, status MSP, wyniki silnika. | |
| To jest kluczowy element dla najwyższej wiarygodności — dokument można załączyć do wniosku lub pokazać instytucji. | |
| """ | |
| try: | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle | |
| from reportlab.lib.units import cm | |
| from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable | |
| from reportlab.lib import colors | |
| from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY | |
| import datetime | |
| doc = SimpleDocTemplate( | |
| output_path, | |
| pagesize=A4, | |
| rightMargin=1.5*cm, | |
| leftMargin=1.5*cm, | |
| topMargin=1.5*cm, | |
| bottomMargin=1.5*cm | |
| ) | |
| styles = getSampleStyleSheet() | |
| title_style = ParagraphStyle( | |
| 'TitlePL', | |
| parent=styles['Heading1'], | |
| fontSize=16, | |
| alignment=TA_CENTER, | |
| spaceAfter=12, | |
| textColor=colors.HexColor('#1a365d') | |
| ) | |
| subtitle_style = ParagraphStyle( | |
| 'SubtitlePL', | |
| parent=styles['Normal'], | |
| fontSize=10, | |
| alignment=TA_CENTER, | |
| textColor=colors.HexColor('#2d3748') | |
| ) | |
| body_style = ParagraphStyle( | |
| 'BodyPL', | |
| parent=styles['Normal'], | |
| fontSize=9, | |
| alignment=TA_JUSTIFY, | |
| spaceAfter=6 | |
| ) | |
| small_style = ParagraphStyle( | |
| 'SmallPL', | |
| parent=styles['Normal'], | |
| fontSize=8, | |
| textColor=colors.gray | |
| ) | |
| story = [] | |
| # Header | |
| story.append(Paragraph("ŚWIADECTWO ZGODNOŚCI WERSJI / REGULATION GROUNDING CERTIFICATE", title_style)) | |
| story.append(Paragraph("GrantForge AI — Najwyższy poziom ugruntowania w aktualnych regulaminach i prawie UE", subtitle_style)) | |
| story.append(Spacer(1, 0.4*cm)) | |
| story.append(HRFlowable(width="100%", thickness=2, color=colors.HexColor('#1a365d'))) | |
| story.append(Spacer(1, 0.3*cm)) | |
| # Basic info | |
| now = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") | |
| info_data = [ | |
| ["Projekt:", project_title], | |
| ["Beneficjent:", company_name], | |
| ["Data wygenerowania świadectwa:", now], | |
| ["Wersja silnika:", "Regulation Engine v3 + Live EUR-Lex"], | |
| ] | |
| info_table = Table(info_data, colWidths=[5*cm, 11*cm]) | |
| info_table.setStyle(TableStyle([ | |
| ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), | |
| ('FONTSIZE', (0, 0), (-1, -1), 9), | |
| ('VALIGN', (0, 0), (-1, -1), 'TOP'), | |
| ('BOTTOMPADDING', (0, 0), (-1, -1), 4), | |
| ])) | |
| story.append(info_table) | |
| story.append(Spacer(1, 0.3*cm)) | |
| # Snapshot section | |
| story.append(Paragraph("<b>ŹRÓDŁO PRAWNE (SNAPSHOT REGULAMINU)</b>", body_style)) | |
| if snapshot_data: | |
| snap_text = f""" | |
| <b>Program:</b> {snapshot_data.get('program', 'N/A')}<br/> | |
| <b>Snapshot ID:</b> {snapshot_data.get('id', 'N/A')}<br/> | |
| <b>Version Hash (SHA256 skrót):</b> {snapshot_data.get('version_hash') or version_hash or 'N/A'}<br/> | |
| <b>Data pobrania:</b> {snapshot_data.get('fetched_at', 'N/A')}<br/> | |
| <b>Źródło:</b> {snapshot_data.get('source_url', 'N/A')}<br/> | |
| <b>Wersja dokumentu:</b> {snapshot_data.get('document_version', 'N/A')}<br/> | |
| <b>Obowiązuje od:</b> {snapshot_data.get('effective_date', 'N/A')}<br/> | |
| <b>Instytucja:</b> {snapshot_data.get('source_institution', 'N/A')}<br/> | |
| <b>Liczba kluczowych reguł:</b> {len(snapshot_data.get('key_rules', []))}<br/> | |
| <b>Liczba wykluczeń:</b> {len(snapshot_data.get('exclusions', []))} | |
| """ | |
| story.append(Paragraph(snap_text, body_style)) | |
| else: | |
| story.append(Paragraph("Brak snapshotu — dokument wygenerowany bez pełnego ugruntowania.", body_style)) | |
| story.append(Spacer(1, 0.3*cm)) | |
| # v5.0 Extended Grounding Certificate fields (citation/trap/trust) | |
| if v5_certificate: | |
| story.append(Paragraph("<b>v5.0 MASTER ORCHESTRATOR GROUNDING CERTIFICATE (EXPORTABLE)</b>", body_style)) | |
| v5_text = f""" | |
| <b>Snapshot ID:</b> {v5_certificate.get('snapshot_id', 'N/A')}<br/> | |
| <b>Version Hash:</b> {v5_certificate.get('version_hash', version_hash or 'N/A')}<br/> | |
| <b>Effective Date:</b> {v5_certificate.get('effective_date', 'N/A')}<br/> | |
| <b>Citation Score:</b> {v5_certificate.get('citation_score', 0.0)}<br/> | |
| <b>Trap Risk (Kruczkowski):</b> {v5_certificate.get('trap_risk', 'unknown')}<br/> | |
| <b>Trust Score:</b> {v5_certificate.get('trust_score', 50)}/100<br/> | |
| <b>Data Quality Avg:</b> {v5_certificate.get('data_quality_avg', 0.0)}<br/> | |
| <b>Timestamp:</b> {v5_certificate.get('timestamp', '')}<br/> | |
| <b>Query Intent:</b> {v5_certificate.get('query_intent', '')}<br/> | |
| <b>Synthesis:</b> {str(v5_certificate.get('synthesis_summary', ''))[:180]} | |
| """ | |
| story.append(Paragraph(v5_text, body_style)) | |
| story.append(Spacer(1, 0.2*cm)) | |
| # EUR-Lex | |
| story.append(Paragraph("<b>LIVE LEGAL CONTROL — EUR-LEX (ŹRÓDŁO PRAWY UE)</b>", body_style)) | |
| if eurlex_links: | |
| for link in eurlex_links[:3]: | |
| story.append(Paragraph(f"• {link.get('title', '')} — {link.get('url', '')}", small_style)) | |
| else: | |
| story.append(Paragraph("Brak bezpośrednich linków EUR-Lex w tym świadectwie.", small_style)) | |
| story.append(Spacer(1, 0.3*cm)) | |
| # PKD + MSP | |
| story.append(Paragraph("<b>UZIEMIENIE W DANYCH FIRMY (GUS + MSP)</b>", body_style)) | |
| if pkd_list: | |
| story.append(Paragraph(f"Kody PKD wnioskodawcy (GUS): {', '.join(pkd_list[:5])}", body_style)) | |
| story.append(Paragraph(f"Status MŚP: {msp_status or 'Zweryfikowany przez GraphRAG'}", body_style)) | |
| story.append(Spacer(1, 0.3*cm)) | |
| # Engine checks | |
| if engine_checks: | |
| story.append(Paragraph("<b>WYNIKI AKTYWNEJ WERYFIKACJI (REGULATION ENGINE)</b>", body_style)) | |
| story.append(Paragraph(str(engine_checks), body_style)) | |
| story.append(Spacer(1, 0.5*cm)) | |
| story.append(HRFlowable(width="100%", thickness=1, color=colors.gray)) | |
| story.append(Paragraph( | |
| "To świadectwo potwierdza, że wszystkie wygenerowane treści opierają się na konkretnej, wersjonowanej wersji regulaminu + aktualnych aktach prawnych UE (EUR-Lex). " | |
| "Hash wersji pozwala na późniejszą weryfikację. Dokument jest integralną częścią wniosku i może być przedstawiony instytucji finansującej.", | |
| small_style | |
| )) | |
| story.append(Spacer(1, 0.3*cm)) | |
| story.append(Paragraph("GrantForge AI — Regulation Engine jako źródło prawdy | https://grantforge.ai", subtitle_style)) | |
| doc.build(story) | |
| return True | |
| except ImportError: | |
| print("Brak reportlab — zainstaluj: pip install reportlab") | |
| # Fallback: zapisz jako tekst | |
| with open(output_path.replace('.pdf', '.txt'), 'w', encoding='utf-8') as f: | |
| f.write(f"ŚWIADECTWO ZGODNOŚCI — {project_title}\nHash: {version_hash}\nSnapshot: {snapshot_data}\n") | |
| return False | |
| except Exception as e: | |
| print(f"Błąd generowania Świadectwa PDF: {e}") | |
| return False | |
| def get_pdf_css(template: str) -> str: | |
| if template == "official": | |
| return """ | |
| @page { size: A4; margin: 2.5cm; } | |
| body { font-family: "DejaVu Sans", "Arial", serif; font-size: 11pt; line-height: 1.5; text-align: justify; color: #000; } | |
| h1, h2, h3 { color: #000; page-break-after: avoid; font-family: "DejaVu Sans", "Arial", serif; } | |
| h1 { border-bottom: 2px solid #000; padding-bottom: 5px; text-transform: uppercase; text-align: center; font-size: 16pt; margin-top: 2em; } | |
| h2 { font-size: 14pt; margin-top: 1.5em; } | |
| h3 { font-size: 12pt; margin-top: 1.2em; font-style: italic; } | |
| table { width: 100%; border-collapse: collapse; margin: 1em 0; page-break-inside: avoid; } | |
| th, td { border: 1px solid #000; padding: 8px; text-align: left; } | |
| p { margin-bottom: 1em; orphans: 3; widows: 3; } | |
| a { color: #000; text-decoration: none; } | |
| .toc { page-break-after: always; } | |
| .toc ul { list-style-type: none; padding-left: 1.5em; } | |
| .toc > ul { padding-left: 0; } | |
| .toc a { text-decoration: none; color: #000; } | |
| """ | |
| elif template == "modern": | |
| return """ | |
| @page { size: A4; margin: 2.5cm; } | |
| body { font-family: "DejaVu Sans", "Arial", sans-serif; font-size: 11pt; line-height: 1.6; color: #1e293b; background: #fff; } | |
| h1, h2, h3 { page-break-after: avoid; color: #0f172a; font-family: "DejaVu Sans", "Arial", sans-serif; } | |
| h1 { border-bottom: 2px solid #3b82f6; padding-bottom: 0.5em; font-size: 24pt; margin-top: 1em; } | |
| h2 { border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3em; font-size: 18pt; margin-top: 1.5em; color: #2563eb; } | |
| h3 { font-size: 14pt; margin-top: 1.2em; color: #334155; } | |
| p { margin-bottom: 1em; text-align: justify; orphans: 3; widows: 3; } | |
| table { width: 100%; border-collapse: collapse; margin: 1.5em 0; background: #f8fafc; font-size: 10pt; page-break-inside: avoid; } | |
| th, td { border: 1px solid #e2e8f0; padding: 10px 14px; text-align: left; } | |
| th { background-color: #e2e8f0; color: #1e293b; font-weight: bold; border-bottom: 2px solid #cbd5e1; } | |
| tr:nth-child(even) { background-color: #f1f5f9; } | |
| ul, ol { margin-bottom: 1em; padding-left: 2em; } | |
| li { margin-bottom: 0.5em; } | |
| .toc { page-break-after: always; padding: 2em; background: #f8fafc; border-radius: 8px; } | |
| .toc ul { list-style-type: none; padding-left: 1.5em; } | |
| .toc > ul { padding-left: 0; } | |
| .toc a { text-decoration: none; color: #4f46e5; border-bottom: 1px dotted #cbd5e1; display: block; padding-bottom: 5px; margin-bottom: 5px; } | |
| """ | |
| elif template == "enterprise": | |
| return """ | |
| @page { size: A4; margin: 2.5cm; } | |
| body { font-family: "DejaVu Sans", "Arial", sans-serif; font-size: 11pt; line-height: 1.6; color: #1f2937; background: #fff; } | |
| h1, h2, h3 { page-break-after: avoid; color: #1e3a8a; font-family: "DejaVu Sans", "Arial", sans-serif; } | |
| h1 { border-bottom: 2px solid #10b981; padding-bottom: 0.5em; font-size: 24pt; margin-top: 1em; } | |
| h2 { border-bottom: 1px solid #e5e7eb; padding-bottom: 0.3em; font-size: 18pt; margin-top: 1.5em; color: #1e40af; } | |
| h3 { font-size: 14pt; margin-top: 1.2em; color: #374151; } | |
| p { margin-bottom: 1em; text-align: justify; orphans: 3; widows: 3; } | |
| table { width: 100%; border-collapse: collapse; margin: 1.5em 0; background: #ffffff; } | |
| th, td { border: 1px solid #d1d5db; padding: 12px; text-align: left; } | |
| th { background-color: #f3f4f6; color: #1f2937; font-weight: bold; } | |
| ul, ol { margin-bottom: 1em; padding-left: 2em; } | |
| li { margin-bottom: 0.5em; } | |
| .toc { page-break-after: always; padding: 2em; background: #f9fafb; border-radius: 8px; border-left: 4px solid #10b981; } | |
| .toc ul { list-style-type: none; padding-left: 1.5em; } | |
| .toc > ul { padding-left: 0; } | |
| .toc a { text-decoration: none; color: #1e3a8a; border-bottom: 1px dotted #9ca3af; display: block; padding-bottom: 5px; margin-bottom: 5px; } | |
| """ | |
| else: # standard | |
| return """ | |
| @page { size: A4; margin: 2cm; } | |
| body { font-family: "DejaVu Sans", "Arial", sans-serif; font-size: 11pt; line-height: 1.6; color: #333; } | |
| h1, h2, h3 { page-break-after: avoid; } | |
| h1 { border-bottom: 2px solid #3498db; padding-bottom: 10px; color: #2c3e50; } | |
| h2 { color: #2980b9; margin-top: 1.5em; } | |
| table { width: 100%; border-collapse: collapse; margin: 1em 0; } | |
| th, td { border: 1px solid #bdc3c7; padding: 8px; text-align: left; } | |
| th { background-color: #ecf0f1; } | |
| p { margin-bottom: 1em; text-align: justify; orphans: 3; widows: 3; } | |
| .toc { page-break-after: always; margin-top: 2em; } | |
| .toc ul { list-style-type: none; padding-left: 1.5em; } | |
| .toc > ul { padding-left: 0; } | |
| .toc a { text-decoration: none; color: #2980b9; border-bottom: 1px dotted #bdc3c7; display: block; padding-bottom: 5px; margin-bottom: 5px; } | |
| """ | |
| def export_to_pdf( | |
| content: str, | |
| output_path: str, | |
| template: str = "standard", | |
| project_title: str = "Wniosek o Dofinansowanie", | |
| company_name: str = "Brak nazwy", | |
| version: str = "1.0", | |
| date_str: str = "", | |
| extra_context: dict = None, | |
| ): | |
| """ | |
| Eksportuje wniosek do PDF wykorzystując WeasyPrint. | |
| """ | |
| if extra_context is None: | |
| extra_context = {} | |
| try: | |
| from xhtml2pdf import pisa | |
| from reportlab.pdfbase.ttfonts import TTFont | |
| from reportlab.pdfbase import pdfmetrics | |
| import xhtml2pdf.default | |
| except ImportError: | |
| print("Nie mozna pobrac xhtml2pdf.") | |
| raise Exception("Należy zainstalować xhtml2pdf (pip install xhtml2pdf).") | |
| try: | |
| # Usuwamy ewentualny nadmiarowy title na poczatku markdown by nie dublowac cover page | |
| if content.startswith("# "): | |
| content = "\n".join(content.split("\n")[1:]) | |
| md_content = f"[TOC]\n\n{content}" | |
| html_body = markdown.markdown( | |
| md_content, | |
| extensions=["tables", "fenced_code", "toc"], | |
| extension_configs={'toc': {'title': 'Spis Treści'}} | |
| ) | |
| import urllib.request | |
| backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) | |
| font_dir = os.path.join(backend_dir, "assets") | |
| os.makedirs(font_dir, exist_ok=True) | |
| dejavu_path = os.path.join(backend_dir, "DejaVuSans.ttf") | |
| dejavu_bold_path = os.path.join(backend_dir, "DejaVuSans-Bold.ttf") | |
| if not os.path.exists(dejavu_path): | |
| try: | |
| urllib.request.urlretrieve( | |
| "https://cdn.jsdelivr.net/npm/@vintproykt/dejavu-fonts-ttf/ttf/DejaVuSans.ttf", | |
| dejavu_path, | |
| ) | |
| except Exception as e: | |
| print(f"Nie udało się pobrać DejaVuSans: {e}") | |
| if not os.path.exists(dejavu_bold_path): | |
| try: | |
| urllib.request.urlretrieve( | |
| "https://cdn.jsdelivr.net/npm/@vintproykt/dejavu-fonts-ttf/ttf/DejaVuSans-Bold.ttf", | |
| dejavu_bold_path, | |
| ) | |
| except Exception as e: | |
| print(f"Nie udało się pobrać DejaVuSans-Bold: {e}") | |
| font_face = "" | |
| if os.path.exists(dejavu_path): | |
| pdfmetrics.registerFont(TTFont("DejaVu Sans", dejavu_path)) | |
| if os.path.exists(dejavu_bold_path): | |
| pdfmetrics.registerFont(TTFont("DejaVu Sans-Bold", dejavu_bold_path)) | |
| xhtml2pdf.default.DEFAULT_FONT["helvetica"] = "DejaVu Sans" | |
| xhtml2pdf.default.DEFAULT_FONT["sans-serif"] = "DejaVu Sans" | |
| xhtml2pdf.default.DEFAULT_FONT["arial"] = "DejaVu Sans" | |
| font_face = f""" | |
| @font-face {{ font-family: "DejaVu Sans"; src: url("file://{dejavu_path}"); }} | |
| """ | |
| if os.path.exists(dejavu_bold_path): | |
| font_face += f""" | |
| @font-face {{ font-family: "DejaVu Sans"; font-weight: bold; src: url("file://{dejavu_bold_path}"); }} | |
| """ | |
| css_style = font_face + get_pdf_css(template) | |
| if not date_str: | |
| date_str = datetime.datetime.now().strftime("%d.%m.%Y") | |
| logo_html = "" | |
| if template == "enterprise": | |
| logo_html = '<div style="color: #10b981; font-size: 24pt; font-weight: bold; margin-bottom: 20px;">♦ GrantForge</div>' | |
| # Cert logic | |
| cert_html = "" | |
| if extra_context and extra_context.get("include_grounding_certificate"): | |
| cert = extra_context | |
| snap = cert.get("snapshot_data", {}) | |
| cert_html = f""" | |
| <pdf:nextpage /> | |
| <div style="margin-top: 50px;"> | |
| <h1 style="color: #1a365d; text-align: center; border-bottom: none;">ŚWIADECTWO ZGODNOŚCI WERSJI / REGULATION GROUNDING CERTIFICATE</h1> | |
| <p style="text-align: center; color: #2d3748;">GrantForge AI — Najwyższy poziom ugruntowania w regulaminach i prawie UE</p> | |
| <hr style="border: 1px solid #1a365d; margin-top: 20px; margin-bottom: 20px;" /> | |
| <p><b>Projekt:</b> {project_title}</p> | |
| <p><b>Beneficjent:</b> {company_name}</p> | |
| <p><b>Data wygenerowania:</b> {datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")}</p> | |
| <p><b>Version Hash:</b> {cert.get("version_hash", "N/A")}</p> | |
| <p><b>Precise Regulation URL:</b> {cert.get("precise_regulation_url", "N/A")}</p> | |
| <p><b>Link Quality:</b> {cert.get("regulation_link_quality", "N/A")}</p> | |
| """ | |
| if snap: | |
| cert_html += "<h3>SNAPSHOT REGULAMINU (źródło prawdy)</h3><ul>" | |
| for k, v in snap.items(): | |
| if k not in ["key_rules", "exclusions"]: | |
| cert_html += f"<li><b>{k}:</b> {v}</li>" | |
| cert_html += "</ul>" | |
| cert_html += """ | |
| <hr style="border: 1px solid #cbd5e1; margin-top: 30px; margin-bottom: 20px;" /> | |
| <p style="text-align: justify; font-size: 9pt; color: #64748b;"> | |
| To świadectwo potwierdza, że wszystkie wygenerowane treści opierają się na konkretnej, wersjonowanej wersji regulaminu oraz aktualnych aktach prawnych UE. | |
| </p> | |
| <p style="text-align: center; font-size: 10pt; color: #475569;"> | |
| https://grantforge.ai | Regulation Engine jako źródło prawdy | |
| </p> | |
| </div> | |
| """ | |
| html_content = f""" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <style> | |
| {css_style} | |
| .cover-page {{ | |
| text-align: center; | |
| padding-top: 250px; | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="cover-page"> | |
| {logo_html} | |
| <h1 style="border: none; font-size: 32pt; margin-bottom: 20px;">{project_title}</h1> | |
| <p style="font-size: 16pt; color: #2c3e50; font-weight: bold;">{company_name}</p> | |
| <div style="margin-top: 150px;"> | |
| <p style="font-size: 11pt; color: #95a5a6; font-weight: bold; margin-bottom: 5px;">Wygenerowano z użyciem systemu wsparcia DotacjeAI</p> | |
| <p style="font-size: 10pt; color: #95a5a6;">Dokument utworzony: {date_str} | Wersja: {version}</p> | |
| </div> | |
| </div> | |
| <!-- Wymuszenie nowej strony po stronie tytułowej --> | |
| <pdf:nextpage /> | |
| {html_body} | |
| {cert_html} | |
| </body> | |
| </html> | |
| """ | |
| with open(output_path, "wb") as pdf_file: | |
| pisa_status = pisa.CreatePDF(html_content.encode("utf-8"), dest=pdf_file, encoding='utf-8') | |
| if pisa_status.err: | |
| print(f"Błąd pisa: {pisa_status.err}") | |
| return False | |
| return True | |
| except Exception: | |
| import traceback | |
| print(f"Błąd eksportu do PDF: {traceback.format_exc()}") | |
| return False | |
| def localize_markdown_headings(content: str, lang: str = "pl") -> str: | |
| """Map English section headings to Polish (lang=pl).""" | |
| if lang != "pl" or not content: | |
| return content | |
| mapping = { | |
| "Executive Summary": "Streszczenie wykonawcze", | |
| "Project Description": "Opis projektu", | |
| "Budget": "Budżet", | |
| "Timeline": "Harmonogram", | |
| "Risk Analysis": "Analiza ryzyka", | |
| "Expected Results": "Oczekiwane rezultaty", | |
| } | |
| lines = content.splitlines() | |
| out: list[str] = [] | |
| for line in lines: | |
| new_line = line | |
| for en, pl in mapping.items(): | |
| if line.strip() == f"# {en}": | |
| new_line = f"# {pl}" | |
| break | |
| if line.strip() == f"## {en}": | |
| new_line = f"## {pl}" | |
| break | |
| if line.strip() == en: | |
| new_line = pl | |
| break | |
| out.append(new_line) | |
| return "\n".join(out) | |