ktg-plugin-marketplace/plugins/ms-ai-architect/scripts/export-pdf.py
Kjell Tore Guttormsen 6a7632146e feat(ms-ai-architect): add plugin to open marketplace (v1.5.0 baseline)
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>
2026-04-07 17:17:17 +02:00

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()