#!/usr/bin/env python3 """render_legal_pdf.py — rende un deliverable markdown in un file .pdf con fpdf2. La logica fpdf2 (sanitize per windows-1252, slug del nome file, scrittura del file e ritorno del path) è COPIATA da src/tools/legal_citations.py (NON importata) per mantenere lo skill autonomo dal server MCP. Niente LibreOffice (non installato). Uso: python render_legal_pdf.py --input deliverable.md \ [--title "Titolo"] [--slug nome-file] [--dir /cartella/attiva] In alternativa il markdown arriva da stdin: cat deliverable.md | python render_legal_pdf.py --title "Parere" Output: /.pdf — il path assoluto viene stampato su stdout. """ import argparse import os import re import sys import unicodedata from fpdf import FPDF from fpdf.enums import XPos, YPos # --------------------------------------------------------------------------- # Helpers copied from legal_citations.py (do NOT import that module) # --------------------------------------------------------------------------- def _sanitize_for_pdf(text: str) -> str: """Replace characters not in windows-1252 for fpdf2 built-in fonts.""" replacements = { "–": "-", "—": "--", "‘": "'", "’": "'", "“": '"', "”": '"', "…": "...", " ": " ", "​": "", "•": "-", # bullet glyph -> hyphen for built-in fonts "«": '"', # « "»": '"', # » } for old, new in replacements.items(): text = text.replace(old, new) return text.encode("windows-1252", errors="replace").decode("windows-1252") def _safe_filename(name: str) -> str: """Convert a title to a safe filename (mirror of legal_citations._safe_filename).""" return re.sub(r"[^\w\-.]", "_", name.replace("/", "-").replace(" ", "_"))[:80] def _slugify(value: str) -> str: """Lowercase, accent-stripped, hyphenated slug for the output file name.""" base = unicodedata.normalize("NFKD", value or "documento") base = base.encode("ascii", "ignore").decode("ascii").lower() base = re.sub(r"[^a-z0-9]+", "-", base).strip("-") return (base or "documento")[:80] # --------------------------------------------------------------------------- # Minimal markdown stripping for inline emphasis / code # --------------------------------------------------------------------------- _INLINE_RE = re.compile(r"\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`([^`]+)`") def _strip_inline(text: str) -> str: """Drop inline markdown markers, keep the content (PDF uses built-in fonts).""" def repl(m: "re.Match") -> str: return next(g for g in m.groups() if g is not None) return _INLINE_RE.sub(repl, text) # --------------------------------------------------------------------------- # PDF generation — fpdf2 "write file, return path" pattern from legal_citations # --------------------------------------------------------------------------- def _cell(pdf: FPDF, height: float, text: str) -> None: """multi_cell that always returns the cursor to the left margin on the next line. Without forcing XPos/YPos, fpdf2 may leave x at the right edge, so a subsequent multi_cell(w=0) computes a near-zero width and raises 'Not enough horizontal space to render a single character'.""" pdf.multi_cell(0, height, text, new_x=XPos.LMARGIN, new_y=YPos.NEXT) def _generate_pdf_from_markdown(title: str, markdown: str, output_path: str) -> None: """Render markdown to PDF with fpdf2, mapping a small block subset to styles.""" pdf = FPDF() pdf.set_auto_page_break(auto=True, margin=15) pdf.add_page() # Title pdf.set_font("Helvetica", "B", 14) _cell(pdf, 8, _sanitize_for_pdf(title)) pdf.ln(4) lines = markdown.replace("\r\n", "\n").split("\n") i = 0 while i < len(lines): raw = lines[i] line = raw.strip() i += 1 if line == "": pdf.ln(3) continue # Horizontal rule if re.match(r"^([-*_]\s*){3,}$", line): y = pdf.get_y() pdf.line(pdf.l_margin, y, pdf.w - pdf.r_margin, y) pdf.ln(3) continue # Headings h = re.match(r"^(#{1,6})\s+(.*)$", line) if h: level = min(len(h.group(1)), 3) size = {1: 13, 2: 12, 3: 11}[level] pdf.ln(2) pdf.set_font("Helvetica", "B", size) _cell(pdf, 6, _sanitize_for_pdf(_strip_inline(h.group(2).strip()))) pdf.ln(1) continue # Blockquote — indent via left/right margins so multi_cell wraps safely, # then always restore the original margins. if re.match(r"^>\s?", line): content = re.sub(r"^>\s?", "", line) pdf.set_font("Helvetica", "I", 10) orig_l, orig_r = pdf.l_margin, pdf.r_margin pdf.set_left_margin(orig_l + 8) pdf.set_right_margin(orig_r + 8) pdf.set_x(pdf.l_margin) _cell(pdf, 5, _sanitize_for_pdf(_strip_inline(content))) pdf.set_left_margin(orig_l) pdf.set_right_margin(orig_r) pdf.set_x(orig_l) continue # List items (unordered or ordered) ul = re.match(r"^[-*+]\s+(.*)$", line) ol = re.match(r"^(\d+)[.)]\s+(.*)$", line) if ul: pdf.set_font("Helvetica", "", 10) _cell(pdf, 5, _sanitize_for_pdf("- " + _strip_inline(ul.group(1)))) continue if ol: pdf.set_font("Helvetica", "", 10) _cell(pdf, 5, _sanitize_for_pdf(f"{ol.group(1)}. " + _strip_inline(ol.group(2)))) continue # Table row -> render as a single line with pipe separators preserved if "|" in line and not re.match(r"^\s*\|?\s*:?-{2,}", line): pdf.set_font("Helvetica", "", 9) cells = [c.strip() for c in line.strip().strip("|").split("|")] _cell(pdf, 5, _sanitize_for_pdf(_strip_inline(" | ".join(cells)))) continue # Skip table separator rows if re.match(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$", line): continue # Default: body paragraph pdf.set_font("Helvetica", "", 10) _cell(pdf, 5, _sanitize_for_pdf(_strip_inline(line))) pdf.output(output_path) def _read_input(input_path: str | None) -> str: if input_path: with open(input_path, "r", encoding="utf-8") as fh: return fh.read() return sys.stdin.read() def _derive_title(markdown: str) -> str: for raw in markdown.split("\n"): h = re.match(r"^#{1,6}\s+(.*)$", raw.strip()) if h: return h.group(1).strip() return "Documento" def main() -> int: parser = argparse.ArgumentParser(description="Render markdown to PDF (fpdf2).") parser.add_argument("--input", "-i", default=None, help="Markdown file (stdin if omitted)") parser.add_argument("--title", "-t", default=None, help="Document title") parser.add_argument("--slug", "-s", default=None, help="Output file slug") parser.add_argument("--dir", "-d", default=None, help="Active folder (cwd if omitted)") args = parser.parse_args() markdown = _read_input(args.input) if not markdown or not markdown.strip(): print("FAIL: nessun contenuto markdown fornito (usa --input o stdin).", file=sys.stderr) return 1 title = args.title or _derive_title(markdown) slug = _slugify(args.slug or title) out_dir = os.path.abspath(args.dir) if args.dir else os.getcwd() os.makedirs(out_dir, exist_ok=True) output_path = os.path.join(out_dir, f"{slug}.pdf") _generate_pdf_from_markdown(_strip_inline(title), markdown, output_path) # Echo the absolute path so the skill can report it. print(output_path) return 0 if __name__ == "__main__": sys.exit(main())