205 lines
11 KiB
Python
205 lines
11 KiB
Python
"""Documenti PDF: impagina in A4 un testo in Markdown (titoli, elenchi, grassetto, corsivo, tabelle, immagini),
|
|
lo salva in data/documenti/ e lo apre in Anteprima. Il file arriva anche al telefono.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
from avatar.settings import DATA_DIR # noqa: E402
|
|
|
|
OUT_DIR = DATA_DIR / "documenti"
|
|
FONT_DIR = Path("/System/Library/Fonts/Supplemental")
|
|
|
|
|
|
def _fonts():
|
|
from reportlab.pdfbase import pdfmetrics
|
|
from reportlab.pdfbase.ttfonts import TTFont
|
|
from reportlab.lib.fonts import addMapping
|
|
if "Arial" in pdfmetrics.getRegisteredFontNames():
|
|
return "Arial"
|
|
try:
|
|
pdfmetrics.registerFont(TTFont("Arial", str(FONT_DIR / "Arial.ttf")))
|
|
pdfmetrics.registerFont(TTFont("Arial-Bold", str(FONT_DIR / "Arial Bold.ttf")))
|
|
pdfmetrics.registerFont(TTFont("Arial-Italic", str(FONT_DIR / "Arial Italic.ttf")))
|
|
pdfmetrics.registerFont(TTFont("Arial-BoldItalic", str(FONT_DIR / "Arial Bold Italic.ttf")))
|
|
addMapping("Arial", 0, 0, "Arial"); addMapping("Arial", 1, 0, "Arial-Bold")
|
|
addMapping("Arial", 0, 1, "Arial-Italic"); addMapping("Arial", 1, 1, "Arial-BoldItalic")
|
|
return "Arial"
|
|
except Exception:
|
|
return "Helvetica"
|
|
|
|
|
|
def _inline(text: str) -> str:
|
|
"""Markdown in linea → mini-HTML di reportlab (grassetto, corsivo, codice, link)."""
|
|
t = html.escape(text, quote=False)
|
|
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
|
|
t = re.sub(r"(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)", r"<i>\1</i>", t)
|
|
t = re.sub(r"`(.+?)`", r"<font face='Courier'>\1</font>", t)
|
|
t = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"<link href='\2' color='#0645ad'>\1</link>", t)
|
|
return t
|
|
|
|
|
|
def costruisci(titolo: str, markdown: str, path: Path, autore: str = "", immagini: list[str] | None = None) -> None:
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.lib.units import mm
|
|
from reportlab.lib import colors
|
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, ListFlowable, ListItem, Table, TableStyle, Image, PageBreak, HRFlowable
|
|
font = _fonts()
|
|
st = {
|
|
"title": ParagraphStyle("t", fontName=font, fontSize=22, leading=27, spaceAfter=10, textColor=colors.HexColor("#123")),
|
|
"h1": ParagraphStyle("h1", fontName=font, fontSize=16, leading=20, spaceBefore=12, spaceAfter=6, textColor=colors.HexColor("#123")),
|
|
"h2": ParagraphStyle("h2", fontName=font, fontSize=13, leading=17, spaceBefore=10, spaceAfter=4),
|
|
"h3": ParagraphStyle("h3", fontName=font, fontSize=11.5, leading=15, spaceBefore=8, spaceAfter=3),
|
|
"p": ParagraphStyle("p", fontName=font, fontSize=10.5, leading=15, spaceAfter=6),
|
|
"quote": ParagraphStyle("q", fontName=font, fontSize=10.5, leading=15, leftIndent=14, textColor=colors.HexColor("#444"), spaceAfter=6),
|
|
"small": ParagraphStyle("s", fontName=font, fontSize=8.5, leading=11, textColor=colors.HexColor("#777")),
|
|
"code": ParagraphStyle("c", fontName="Courier", fontSize=9, leading=12, backColor=colors.HexColor("#f4f4f4"), leftIndent=6, spaceAfter=6),
|
|
}
|
|
story = []
|
|
if titolo:
|
|
story += [Paragraph(_inline(titolo), st["title"]), HRFlowable(width="100%", color=colors.HexColor("#123"), thickness=0.8, spaceAfter=8)]
|
|
if autore:
|
|
story.append(Paragraph(html.escape(autore), st["small"])); story.append(Spacer(1, 6))
|
|
lines = (markdown or "").replace("\r", "").split("\n")
|
|
i, para, lst, table, code = 0, [], None, None, None
|
|
|
|
def flush_para():
|
|
nonlocal para
|
|
if para:
|
|
story.append(Paragraph(_inline(" ".join(para)), st["p"])); para = []
|
|
|
|
def flush_list():
|
|
nonlocal lst
|
|
if lst:
|
|
kind, items = lst
|
|
story.append(ListFlowable([ListItem(Paragraph(_inline(x), st["p"]), leftIndent=12) for x in items],
|
|
bulletType="1" if kind == "ol" else "bullet", start="1" if kind == "ol" else None, bulletFormat="%s." if kind == "ol" else None, leftIndent=14, bulletFontName=font, bulletFontSize=9))
|
|
lst = None
|
|
|
|
def flush_table():
|
|
nonlocal table
|
|
if table:
|
|
rows = [[Paragraph(_inline(c), st["p"]) for c in r] for r in table]
|
|
t = Table(rows, hAlign="LEFT", repeatRows=1)
|
|
t.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#999")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8eef3")),
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5)]))
|
|
story.append(t); story.append(Spacer(1, 8)); table = None
|
|
|
|
while i < len(lines):
|
|
ln = lines[i].rstrip()
|
|
if code is not None:
|
|
if ln.strip().startswith("```"):
|
|
story.append(Paragraph(html.escape("\n".join(code)).replace("\n", "<br/>"), st["code"])); code = None
|
|
else:
|
|
code.append(ln)
|
|
i += 1; continue
|
|
if ln.strip().startswith("```"):
|
|
flush_para(); flush_list(); flush_table(); code = []; i += 1; continue
|
|
if not ln.strip():
|
|
flush_para(); flush_list(); flush_table(); i += 1; continue
|
|
if ln.strip() in ("---", "***", "___"):
|
|
flush_para(); flush_list(); flush_table(); story.append(HRFlowable(width="100%", color=colors.HexColor("#bbb"), spaceBefore=4, spaceAfter=8)); i += 1; continue
|
|
if ln.strip().lower() in ("\\pagebreak", "<pagebreak>", "[pagina]"):
|
|
flush_para(); flush_list(); flush_table(); story.append(PageBreak()); i += 1; continue
|
|
m = re.match(r"^(#{1,3})\s+(.*)$", ln)
|
|
if m:
|
|
flush_para(); flush_list(); flush_table()
|
|
story.append(Paragraph(_inline(m.group(2)), st[f"h{len(m.group(1))}"])); i += 1; continue
|
|
m = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)", ln.strip())
|
|
if m:
|
|
flush_para(); flush_list(); flush_table()
|
|
p = Path(m.group(2)).expanduser()
|
|
if p.exists():
|
|
img = Image(str(p)); ratio = img.imageHeight / img.imageWidth; w = min(160 * mm, img.imageWidth * 0.264 * mm)
|
|
img.drawWidth, img.drawHeight = w, w * ratio; story.append(img)
|
|
if m.group(1):
|
|
story.append(Paragraph(html.escape(m.group(1)), st["small"]))
|
|
story.append(Spacer(1, 8))
|
|
i += 1; continue
|
|
if ln.strip().startswith("|"):
|
|
flush_para(); flush_list()
|
|
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
|
|
if not all(re.fullmatch(r":?-{2,}:?", c) for c in cells):
|
|
table = (table or []) + [cells]
|
|
i += 1; continue
|
|
m = re.match(r"^\s*([-*•]|\d+[.)])\s+(.*)$", ln)
|
|
if m:
|
|
flush_para(); flush_table()
|
|
kind = "ol" if m.group(1)[0].isdigit() else "ul"
|
|
if lst and lst[0] != kind:
|
|
flush_list()
|
|
lst = (kind, (lst[1] if lst else []) + [m.group(2)]); i += 1; continue
|
|
if ln.startswith(">"):
|
|
flush_para(); flush_list(); flush_table()
|
|
story.append(Paragraph(_inline(ln.lstrip("> ")), st["quote"])); i += 1; continue
|
|
flush_list(); flush_table(); para.append(ln.strip()); i += 1
|
|
flush_para(); flush_list(); flush_table()
|
|
for p in immagini or []:
|
|
pp = Path(p).expanduser()
|
|
if pp.exists():
|
|
img = Image(str(pp)); ratio = img.imageHeight / img.imageWidth; w = min(160 * mm, img.imageWidth * 0.264 * mm)
|
|
img.drawWidth, img.drawHeight = w, w * ratio; story += [Spacer(1, 8), img]
|
|
|
|
def footer(canvas, doc):
|
|
canvas.saveState(); canvas.setFont(font, 8); canvas.setFillColor(colors.HexColor("#888"))
|
|
canvas.drawRightString(A4[0] - 20 * mm, 12 * mm, f"{doc.page}")
|
|
if titolo:
|
|
canvas.drawString(20 * mm, 12 * mm, titolo[:80])
|
|
canvas.restoreState()
|
|
|
|
doc = SimpleDocTemplate(str(path), pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=20 * mm, bottomMargin=20 * mm,
|
|
title=titolo or path.stem, author=autore or "LuZa")
|
|
doc.build(story or [Paragraph("(documento vuoto)", st["p"])], onFirstPage=footer, onLaterPages=footer)
|
|
|
|
|
|
def crea(params: dict, ctx: dict) -> str:
|
|
titolo = str(params.get("titolo") or "").strip()
|
|
contenuto = str(params.get("contenuto") or "").strip()
|
|
if not contenuto and not titolo:
|
|
return "Errore: serve il contenuto del documento (in Markdown)."
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
nome = re.sub(r"[^a-z0-9]+", "-", str(params.get("nome_file") or titolo or "documento").lower()).strip("-")[:60] or "documento"
|
|
path = OUT_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}-{nome}.pdf"
|
|
immagini = params.get("immagini") or []
|
|
if isinstance(immagini, str):
|
|
immagini = [immagini]
|
|
try:
|
|
costruisci(titolo, contenuto, path, str(params.get("autore") or ""), [str(x) for x in immagini])
|
|
except Exception as err:
|
|
return f"Impaginazione fallita: {err}"
|
|
if params.get("mostra", True) not in (False, "false", "no"):
|
|
subprocess.Popen(["open", str(path)])
|
|
pagine = ""
|
|
try:
|
|
from pypdf import PdfReader
|
|
pagine = f", {len(PdfReader(str(path)).pages)} pagine"
|
|
except Exception:
|
|
pass
|
|
return f"PDF creato: {path}{pagine}. Aperto in Anteprima.\nFILE: {path}"
|
|
|
|
|
|
def ultimi(params: dict, ctx: dict) -> str:
|
|
files = sorted(OUT_DIR.glob("*.pdf"), key=lambda p: p.stat().st_mtime, reverse=True) if OUT_DIR.exists() else []
|
|
if not files:
|
|
return "Nessun documento creato finora."
|
|
if str(params.get("apri", "")).lower() in ("true", "1", "sì", "si"):
|
|
subprocess.Popen(["open", str(files[0])])
|
|
n = max(1, min(int(params.get("numero") or 5), 20))
|
|
return "Ultimi documenti:\n" + "\n".join(f"- {p.name}" for p in files[:n]) + f"\nFILE: {files[0]}"
|
|
|
|
|
|
TOOLS = [
|
|
{"name": "pdf_crea", "description": "Crea un documento PDF in A4 da un testo in Markdown: usa # ## ### per i titoli, - o 1. per gli elenchi, **grassetto**, *corsivo*, tabelle con |,  per le immagini, --- per una riga, [pagina] per il salto pagina. Scrivi tu il contenuto completo e ben strutturato (lettere, relazioni, elenchi, ricette, verbali…). Il PDF viene salvato, aperto in Anteprima e inviato al telefono.",
|
|
"parameters": {"type": "object", "properties": {"titolo": {"type": "string"}, "contenuto": {"type": "string", "description": "testo in Markdown"}, "nome_file": {"type": "string"}, "autore": {"type": "string"},
|
|
"immagini": {"type": "array", "items": {"type": "string"}, "description": "percorsi di immagini da accodare"}, "mostra": {"type": "boolean"}}, "required": ["contenuto"]}, "run": crea},
|
|
{"name": "pdf_ultimi", "description": "Elenca gli ultimi PDF creati (cartella data/documenti); con apri=true apre il più recente.",
|
|
"parameters": {"type": "object", "properties": {"numero": {"type": "integer"}, "apri": {"type": "boolean"}}}, "run": ultimi},
|
|
]
|