Initial addition of ms-ai-architect plugin to the open-source marketplace. Private content excluded: orchestrator/ (Linear tooling), docs/utredning/ (client investigation), generated test reports and PDF export script. skill-gen tooling moved from orchestrator/ to scripts/skill-gen/. Security scan: WARNING (risk 20/100) — no secrets, no injection found. False positive fixed: added gitleaks:allow to Python variable reference in output-validation-grounding-verification.md line 109. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
211 lines
4.6 KiB
Python
Executable file
211 lines
4.6 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
Generate a professional PDF from a markdown file.
|
|
|
|
Requirements:
|
|
pip install markdown weasyprint
|
|
|
|
Usage:
|
|
python scripts/export-pdf.py <input.md> [output.pdf]
|
|
|
|
If output is not specified, uses the same name as input with .pdf extension.
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import markdown
|
|
from weasyprint import HTML
|
|
|
|
# --- CSS ---
|
|
|
|
CSS = """
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
|
|
|
@page {
|
|
size: A4;
|
|
margin: 25mm 20mm 25mm 20mm;
|
|
|
|
@bottom-center {
|
|
content: counter(page);
|
|
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
font-size: 8pt;
|
|
color: #718096;
|
|
}
|
|
}
|
|
|
|
@page :first {
|
|
@bottom-center { content: none; }
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
font-size: 10.5pt;
|
|
line-height: 1.6;
|
|
color: #1a202c;
|
|
max-width: 100%;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 20pt;
|
|
font-weight: 700;
|
|
color: #1a365d;
|
|
margin-top: 32px;
|
|
margin-bottom: 12px;
|
|
page-break-after: avoid;
|
|
}
|
|
|
|
h2 {
|
|
font-size: 15pt;
|
|
font-weight: 700;
|
|
color: #1a365d;
|
|
margin-top: 28px;
|
|
margin-bottom: 10px;
|
|
padding-bottom: 6px;
|
|
border-bottom: 2px solid #e2e8f0;
|
|
page-break-after: avoid;
|
|
}
|
|
|
|
h3 {
|
|
font-size: 12pt;
|
|
font-weight: 600;
|
|
color: #2b6cb0;
|
|
margin-top: 20px;
|
|
margin-bottom: 8px;
|
|
page-break-after: avoid;
|
|
}
|
|
|
|
h4 {
|
|
font-size: 10.5pt;
|
|
font-weight: 600;
|
|
color: #2d3748;
|
|
margin-top: 16px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 12px 0 20px 0;
|
|
font-size: 9pt;
|
|
page-break-inside: auto;
|
|
}
|
|
|
|
thead { display: table-header-group; }
|
|
tr { page-break-inside: avoid; }
|
|
|
|
th {
|
|
background-color: #2b6cb0;
|
|
color: white;
|
|
font-weight: 600;
|
|
text-align: left;
|
|
padding: 8px 10px;
|
|
font-size: 8.5pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
|
|
td {
|
|
padding: 7px 10px;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
vertical-align: top;
|
|
}
|
|
|
|
tr:nth-child(even) td { background-color: #f7fafc; }
|
|
|
|
blockquote {
|
|
border-left: 3px solid #2b6cb0;
|
|
margin: 12px 0;
|
|
padding: 8px 16px;
|
|
background: #ebf8ff;
|
|
color: #2a4365;
|
|
font-size: 10pt;
|
|
border-radius: 0 4px 4px 0;
|
|
}
|
|
|
|
code {
|
|
background: #edf2f7;
|
|
padding: 1px 4px;
|
|
border-radius: 3px;
|
|
font-size: 9pt;
|
|
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
|
}
|
|
|
|
hr {
|
|
border: none;
|
|
border-top: 2px solid #e2e8f0;
|
|
margin: 24px 0;
|
|
}
|
|
|
|
ul, ol { margin: 8px 0 12px 0; padding-left: 24px; }
|
|
li { margin-bottom: 4px; }
|
|
strong { font-weight: 600; color: #1a202c; }
|
|
a { color: #2b6cb0; text-decoration: none; }
|
|
p { margin: 8px 0; }
|
|
|
|
.section-break { page-break-before: always; }
|
|
.score-high { color: #276749; font-weight: 700; }
|
|
.score-medium { color: #d69e2e; font-weight: 700; }
|
|
.score-low { color: #c53030; font-weight: 700; }
|
|
"""
|
|
|
|
|
|
def postprocess_html(html: str) -> str:
|
|
"""Add CSS classes for scores and risk levels."""
|
|
# Section breaks on h2 (except first)
|
|
h2_count = 0
|
|
|
|
def add_section_break(match: re.Match) -> str:
|
|
nonlocal h2_count
|
|
h2_count += 1
|
|
if h2_count > 1:
|
|
return f'<h2 class="section-break">{match.group(1)}</h2>'
|
|
return match.group(0)
|
|
|
|
html = re.sub(r"<h2>(.*?)</h2>", add_section_break, html)
|
|
|
|
# Score coloring: 4/5, 5/5 green; 3/5 yellow; 1/5, 2/5 red
|
|
html = re.sub(r"<td>([45])/5</td>", r'<td><span class="score-high">\1/5</span></td>', html)
|
|
html = re.sub(r"<td>3/5</td>", '<td><span class="score-medium">3/5</span></td>', html)
|
|
html = re.sub(r"<td>([12])/5</td>", r'<td><span class="score-low">\1/5</span></td>', html)
|
|
|
|
return html
|
|
|
|
|
|
def main() -> None:
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python export-pdf.py <input.md> [output.pdf]")
|
|
sys.exit(1)
|
|
|
|
input_path = Path(sys.argv[1])
|
|
if not input_path.exists():
|
|
print(f"Error: {input_path} not found")
|
|
sys.exit(1)
|
|
|
|
output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else input_path.with_suffix(".pdf")
|
|
|
|
md_text = input_path.read_text(encoding="utf-8")
|
|
body_html = markdown.markdown(md_text, extensions=["tables", "smarty", "sane_lists"])
|
|
body_html = postprocess_html(body_html)
|
|
|
|
full_html = f"""<!DOCTYPE html>
|
|
<html lang="no">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>{CSS}</style>
|
|
</head>
|
|
<body>
|
|
{body_html}
|
|
</body>
|
|
</html>"""
|
|
|
|
HTML(string=full_html).write_pdf(str(output_path))
|
|
print(f"PDF generated: {output_path}")
|
|
print(f"Size: {output_path.stat().st_size / 1024:.1f} KB")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|