443 lines
20 KiB
Python
443 lines
20 KiB
Python
import re
|
||
from pathlib import Path
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib.units import cm
|
||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||
from reportlab.lib import colors
|
||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||
from reportlab.platypus import (BaseDocTemplate, PageTemplate, Frame,
|
||
Paragraph, Spacer, Table, TableStyle,
|
||
Image, HRFlowable, PageBreak, NextPageTemplate,
|
||
KeepTogether)
|
||
from reportlab.pdfgen import canvas as rl_canvas
|
||
from datetime import date
|
||
|
||
BASE = Path(__file__).parent
|
||
|
||
AUTORE = "Ufficio Tecnico – Cevolani S.p.A."
|
||
VERSIONE = "1.0"
|
||
STATO = "Definitivo"
|
||
|
||
# ─── Palette ──────────────────────────────────────────────────────────────────
|
||
C_DARK = colors.HexColor("#0f2236")
|
||
C_BLUE = colors.HexColor("#1a3a5c")
|
||
C_MID = colors.HexColor("#2c5f8a")
|
||
C_LIGHT = colors.HexColor("#4a8fc0")
|
||
C_ACCENT = colors.HexColor("#e8f2fa")
|
||
C_GRAY = colors.HexColor("#f6f7f8")
|
||
C_BORDER = colors.HexColor("#b8c8d8")
|
||
C_TEXT = colors.HexColor("#1e1e1e")
|
||
C_WHITE = colors.white
|
||
|
||
W, H = A4
|
||
MARGIN = 2.5 * cm
|
||
CONTENT_W = W - 2 * MARGIN
|
||
|
||
# ─── TableStyle ───────────────────────────────────────────────────────────────
|
||
def make_table_style(header_color=None):
|
||
hc = header_color or C_MID
|
||
return TableStyle([
|
||
("BACKGROUND", (0, 0), (-1, 0), hc),
|
||
("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE),
|
||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||
("FONTSIZE", (0, 0), (-1, 0), 9),
|
||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [C_WHITE, C_GRAY]),
|
||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||
("GRID", (0, 0), (-1, -1), 0.4, C_BORDER),
|
||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||
])
|
||
|
||
TABLE_STD = make_table_style()
|
||
TABLE_DARK = make_table_style(C_DARK)
|
||
|
||
COVER_INFO_STYLE = TableStyle([
|
||
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
|
||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||
("TEXTCOLOR", (0, 0), (0, -1), C_BLUE),
|
||
("TEXTCOLOR", (1, 0), (1, -1), C_TEXT),
|
||
("ROWBACKGROUNDS",(0, 0), (-1, -1), [C_WHITE, C_GRAY]),
|
||
("GRID", (0, 0), (-1, -1), 0.4, C_BORDER),
|
||
("TOPPADDING", (0, 0), (-1, -1), 7),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 7),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 12),
|
||
])
|
||
|
||
|
||
# ─── Parser markdown ──────────────────────────────────────────────────────────
|
||
def parse_md(md_path: Path) -> dict:
|
||
text = md_path.read_text(encoding="utf-8")
|
||
data = {}
|
||
|
||
m = re.search(r"^# (.+)$", text, re.MULTILINE)
|
||
data["nome"] = m.group(1).strip() if m else md_path.parent.name
|
||
|
||
kv = dict(re.findall(r"^\| (.+?) \| (.+?) \|$", text, re.MULTILINE))
|
||
kv = {k.strip(): v.strip() for k, v in kv.items() if k.strip() != "---"}
|
||
|
||
data["t0"] = kv.get("Inizio", "").replace(" s", "")
|
||
data["t_end"] = kv.get("Fine", "").replace(" s", "")
|
||
data["n_punti"] = kv.get("Numero di punti", "")
|
||
data["zona_exc"] = kv.get("Zona esclusa (peso = 0)", None)
|
||
data["r2"] = kv.get("R²", "")
|
||
data["intervallo"] = f"{data['t0']} – {data['t_end']} s"
|
||
|
||
m = re.search(r"\$\$(.+?)\$\$", text, re.DOTALL)
|
||
data["equazione_latex"] = m.group(1).strip() if m else ""
|
||
data["equazione"] = (data["equazione_latex"]
|
||
.replace(r"\cdot", "·").replace(r"\infty", "∞"))
|
||
|
||
rows3 = re.findall(r"^\| (.+?) \| (.+?) \| (.+?) \|$", text, re.MULTILINE)
|
||
params = {r[0].strip(): (r[1].strip(), r[2].strip())
|
||
for r in rows3 if r[0].strip() not in ("Parametro", "---")}
|
||
data["A"] = params.get("A", ("", ""))[0].replace(" °C", "")
|
||
data["A_err"] = (params.get("A", ("", ""))[1]
|
||
.replace("± ", "").replace("+/- ", "").replace(" °C", ""))
|
||
data["tau"] = params.get("τ", ("", ""))[0].replace(" s", "")
|
||
data["tau_err"] = (params.get("τ", ("", ""))[1]
|
||
.replace("± ", "").replace("+/- ", "").replace(" s", ""))
|
||
|
||
pngs = sorted(md_path.parent.glob("*.png"))
|
||
data["png"] = pngs[0] if pngs else None
|
||
return data
|
||
|
||
|
||
def collect_induttori() -> list:
|
||
return [parse_md(md)
|
||
for folder in sorted(BASE.glob("induttore*/"))
|
||
for md in sorted(folder.glob("report*.md"))]
|
||
|
||
|
||
# ─── Canvas con header/footer ─────────────────────────────────────────────────
|
||
class ProfessionalCanvas(rl_canvas.Canvas):
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self._saved = []
|
||
|
||
def showPage(self):
|
||
self._saved.append(dict(self.__dict__))
|
||
self._startPage()
|
||
|
||
def save(self):
|
||
n = len(self._saved)
|
||
for state in self._saved:
|
||
self.__dict__.update(state)
|
||
if self._pageNumber > 1:
|
||
self._draw_header()
|
||
self._draw_footer(n)
|
||
super().showPage()
|
||
super().save()
|
||
|
||
def _draw_header(self):
|
||
self.saveState()
|
||
# Barra colorata in cima
|
||
self.setFillColor(C_DARK)
|
||
self.rect(0, H - 1.4*cm, W, 1.4*cm, fill=1, stroke=0)
|
||
self.setFillColor(C_LIGHT)
|
||
self.rect(0, H - 1.4*cm, 0.5*cm, 1.4*cm, fill=1, stroke=0)
|
||
self.setFillColor(C_WHITE)
|
||
self.setFont("Helvetica-Bold", 9)
|
||
self.drawString(0.8*cm, H - 0.9*cm, "ANALISI TERMICA FORNO")
|
||
self.setFont("Helvetica", 8)
|
||
self.drawRightString(W - 0.6*cm, H - 0.9*cm, "Cevolani S.p.A. — Uso interno")
|
||
self.restoreState()
|
||
|
||
def _draw_footer(self, total):
|
||
self.saveState()
|
||
self.setStrokeColor(C_MID)
|
||
self.setLineWidth(0.8)
|
||
self.line(MARGIN, 1.9*cm, W - MARGIN, 1.9*cm)
|
||
self.setFillColor(C_BLUE)
|
||
self.setFont("Helvetica", 7.5)
|
||
self.drawString(MARGIN, 1.4*cm,
|
||
f"Documento: report_completo | Ver. {VERSIONE} | {STATO}")
|
||
self.setFont("Helvetica-Bold", 8)
|
||
self.drawRightString(W - MARGIN, 1.4*cm,
|
||
f"Pag. {self._pageNumber} / {total}")
|
||
self.restoreState()
|
||
|
||
|
||
# ─── Stili testo ──────────────────────────────────────────────────────────────
|
||
def make_styles():
|
||
styles = getSampleStyleSheet()
|
||
s = {}
|
||
s["body"] = ParagraphStyle("body", parent=styles["BodyText"],
|
||
fontSize=9.5, leading=14, textColor=C_TEXT,
|
||
spaceAfter=6)
|
||
s["h1"] = ParagraphStyle("h1", fontName="Helvetica-Bold", fontSize=14,
|
||
textColor=C_WHITE, spaceBefore=16, spaceAfter=2,
|
||
leftIndent=-0.3*cm, leading=18)
|
||
s["h2"] = ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=10.5,
|
||
textColor=C_BLUE, spaceBefore=10, spaceAfter=4,
|
||
leading=14)
|
||
s["eq"] = ParagraphStyle("eq", fontName="Courier", fontSize=9.5,
|
||
leftIndent=18, spaceBefore=4, spaceAfter=6,
|
||
backColor=C_ACCENT, borderPad=7, leading=14)
|
||
s["caption"] = ParagraphStyle("caption", fontName="Helvetica-Oblique",
|
||
fontSize=8.5, textColor=colors.HexColor("#555555"),
|
||
alignment=TA_CENTER, spaceAfter=4)
|
||
return s
|
||
|
||
|
||
def section_header(title: str, s_h1) -> list:
|
||
"""Barra blu con titolo sezione."""
|
||
tbl = Table([[Paragraph(title, s_h1)]], colWidths=[CONTENT_W])
|
||
tbl.setStyle(TableStyle([
|
||
("BACKGROUND", (0, 0), (-1, -1), C_BLUE),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
||
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||
("BOTTOMPADDING",(0, 0), (-1, -1), 6),
|
||
]))
|
||
return [tbl, Spacer(1, 0.25*cm)]
|
||
|
||
|
||
# ─── Genera .tex ──────────────────────────────────────────────────────────────
|
||
def generate_tex(induttori: list):
|
||
today = date.today().strftime("%d/%m/%Y")
|
||
lines = [
|
||
r"\documentclass[11pt,a4paper]{article}",
|
||
r"\usepackage[utf8]{inputenc}",
|
||
r"\usepackage[T1]{fontenc}",
|
||
r"\usepackage[margin=2.5cm]{geometry}",
|
||
r"\usepackage{amsmath,amssymb}",
|
||
r"\usepackage{booktabs}",
|
||
r"\usepackage{graphicx}",
|
||
r"\usepackage{float}",
|
||
r"\usepackage[table]{xcolor}",
|
||
r"\usepackage{hyperref}",
|
||
r"\usepackage{fancyhdr}",
|
||
r"\definecolor{darkblue}{HTML}{1a3a5c}",
|
||
r"\pagestyle{fancy}\fancyhf{}",
|
||
r"\rhead{\textcolor{darkblue}{\small Cevolani S.p.A.}}",
|
||
r"\lhead{\textcolor{darkblue}{\small Analisi Termica Forno}}",
|
||
r"\rfoot{\thepage}",
|
||
r"\lfoot{\textcolor{gray}{\tiny Ver. " + VERSIONE + r" -- " + today + r"}}",
|
||
rf"\title{{\textbf{{Report Analisi Termica Forno}}\\[0.5em]\large Caratterizzazione termica degli induttori}}",
|
||
rf"\author{{{AUTORE}}}",
|
||
r"\date{\today}",
|
||
r"\begin{document}",
|
||
r"\maketitle",
|
||
r"\tableofcontents",
|
||
r"\newpage",
|
||
"",
|
||
r"\section{Modello di fitting}",
|
||
r"Ogni intervallo è interpolato con il modello di raffreddamento di Newton:",
|
||
r"\begin{equation}",
|
||
r" T(t) = T_\infty + A \cdot e^{-(t-t_0)/\tau}",
|
||
r"\end{equation}",
|
||
r"dove $T_\infty = 22{,}99\,^\circ\mathrm{C}$ è la temperatura ambiente media ponderata.",
|
||
r"La regressione non lineare usa il metodo \textit{Trust Region Reflective} (TRF)",
|
||
r"di \texttt{scipy.optimize.curve\_fit}.",
|
||
"",
|
||
]
|
||
|
||
for ind in induttori:
|
||
lines += [
|
||
rf"\section{{{ind['nome']}}}",
|
||
r"\subsection{Intervallo di campionamento}",
|
||
r"\begin{tabular}{ll}\toprule",
|
||
r"\textbf{Parametro} & \textbf{Valore} \\\midrule",
|
||
rf"Inizio & ${ind['t0']}$ s \\",
|
||
rf"Fine & ${ind['t_end']}$ s \\",
|
||
rf"Numero di punti & {ind['n_punti']} \\",
|
||
]
|
||
if ind["zona_exc"]:
|
||
lines.append(rf"Zona esclusa (peso = 0) & {ind['zona_exc']} \\")
|
||
lines += [r"\bottomrule\end{tabular}", "",
|
||
r"\subsection{Equazione trovata}",
|
||
r"\begin{equation}",
|
||
f" {ind['equazione_latex']}",
|
||
r"\end{equation}",
|
||
r"\begin{tabular}{lll}\toprule",
|
||
r"\textbf{Param.} & \textbf{Valore} & \textbf{Incertezza ($1\sigma$)} \\\midrule",
|
||
rf"$A$ & ${ind['A']}\,^\circ\mathrm{{C}}$ & $\pm\,{ind['A_err']}\,^\circ\mathrm{{C}}$ \\",
|
||
rf"$\tau$ & ${ind['tau']}\,\mathrm{{s}}$ & $\pm\,{ind['tau_err']}\,\mathrm{{s}}$ \\",
|
||
rf"$R^2$ & \multicolumn{{2}}{{l}}{{{ind['r2']}}} \\",
|
||
r"\bottomrule\end{tabular}", "",
|
||
r"\subsection{Grafico}",
|
||
r"\begin{figure}[H]\centering",
|
||
rf"\includegraphics[width=0.9\textwidth]{{{ind['png']}}}",
|
||
rf"\caption{{Fit esponenziale -- {ind['nome']} [{ind['intervallo']}]}}",
|
||
r"\end{figure}\newpage", "",
|
||
]
|
||
|
||
lines += [
|
||
r"\section{Riepilogo}",
|
||
r"\begin{tabular}{lllll}\toprule",
|
||
r"\textbf{Induttore} & \textbf{Intervallo [s]} & \textbf{A [°C]} & \textbf{$\tau$ [s]} & \textbf{$R^2$} \\\midrule",
|
||
]
|
||
for ind in induttori:
|
||
lines.append(
|
||
rf"{ind['nome']} & {ind['intervallo']} & {ind['A']} & {ind['tau']} & {ind['r2']} \\"
|
||
)
|
||
lines += [r"\bottomrule\end{tabular}", "", r"\end{document}"]
|
||
|
||
(BASE / "report_completo.tex").write_text("\n".join(lines), encoding="utf-8")
|
||
print("report_completo.tex generato.")
|
||
|
||
|
||
# ─── Genera PDF ───────────────────────────────────────────────────────────────
|
||
def generate_pdf(induttori: list):
|
||
s = make_styles()
|
||
today_str = date.today().strftime("%d %B %Y")
|
||
|
||
frame_cover = Frame(MARGIN, MARGIN, CONTENT_W, H - 2*MARGIN, id="cover")
|
||
frame_normal = Frame(MARGIN, 2.5*cm, CONTENT_W, H - 2.5*cm - 1.6*cm,
|
||
id="normal", topPadding=0)
|
||
|
||
doc = BaseDocTemplate(str(BASE / "report_completo.pdf"), pagesize=A4)
|
||
doc.addPageTemplates([
|
||
PageTemplate(id="cover", frames=[frame_cover]),
|
||
PageTemplate(id="normal", frames=[frame_normal]),
|
||
])
|
||
|
||
story = []
|
||
|
||
# ── COVER PAGE ──────────────────────────────────────────────────────────────
|
||
# Barra superiore scura
|
||
story.append(Table(
|
||
[[Paragraph("<font color='white' size='11'><b>CEVOLANI S.p.A.</b></font>",
|
||
ParagraphStyle("ch", alignment=TA_LEFT)),
|
||
Paragraph("<font color='#4a8fc0' size='9'>Analisi Termica — Uso Interno</font>",
|
||
ParagraphStyle("cr", alignment=TA_LEFT))]],
|
||
colWidths=[CONTENT_W * 0.55, CONTENT_W * 0.45],
|
||
style=TableStyle([
|
||
("BACKGROUND", (0, 0), (-1, -1), C_DARK),
|
||
("TOPPADDING", (0, 0), (-1, -1), 10),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 14),
|
||
])
|
||
))
|
||
story.append(Spacer(1, 4*cm))
|
||
|
||
# Titolo principale
|
||
story.append(HRFlowable(width="100%", thickness=4, color=C_BLUE))
|
||
story.append(Spacer(1, 0.4*cm))
|
||
story.append(Paragraph(
|
||
"<font color='#1a3a5c' size='26'><b>Report Analisi Termica Forno</b></font>",
|
||
ParagraphStyle("ct", alignment=TA_CENTER, leading=32)))
|
||
story.append(Spacer(1, 0.3*cm))
|
||
story.append(Paragraph(
|
||
"<font color='#2c5f8a' size='13'>Caratterizzazione termica degli induttori</font>",
|
||
ParagraphStyle("cs", alignment=TA_CENTER, leading=18)))
|
||
story.append(Spacer(1, 0.4*cm))
|
||
story.append(HRFlowable(width="100%", thickness=4, color=C_BLUE))
|
||
story.append(Spacer(1, 2*cm))
|
||
|
||
# Scheda documento
|
||
doc_info = [
|
||
["Documento", "report_completo"],
|
||
["Autore", AUTORE],
|
||
["Data di emissione", today_str],
|
||
["Versione", VERSIONE],
|
||
["Stato", STATO],
|
||
["Induttori analizzati", str(len(induttori))],
|
||
]
|
||
story.append(Table(doc_info, colWidths=[5.5*cm, CONTENT_W - 5.5*cm],
|
||
style=COVER_INFO_STYLE))
|
||
story.append(NextPageTemplate("normal"))
|
||
story.append(PageBreak())
|
||
|
||
# ── MODELLO ─────────────────────────────────────────────────────────────────
|
||
story += section_header("1. Modello di fitting", s["h1"])
|
||
story.append(Paragraph(
|
||
"Ogni intervallo di acquisizione è interpolato con la legge di "
|
||
"raffreddamento di Newton, che descrive lo scambio termico convettivo "
|
||
"tra il corpo e l'ambiente:", s["body"]))
|
||
story.append(Paragraph("T(t) = T_inf + A · exp( -(t - t₀) / τ )", s["eq"]))
|
||
story.append(Table([
|
||
["Simbolo", "Significato", "Unità"],
|
||
["T_inf", "Temperatura ambiente (fissa)", "°C"],
|
||
["A", "Ampiezza iniziale (libera)", "°C"],
|
||
["t₀", "Istante di riferimento (fisso)", "s"],
|
||
["τ", "Costante di tempo (libera)", "s"],
|
||
], colWidths=[3*cm, 9*cm, 3*cm], style=TABLE_STD))
|
||
story.append(Spacer(1, 0.3*cm))
|
||
story.append(Paragraph(
|
||
"La regressione non lineare è eseguita con il metodo "
|
||
"<i>Trust Region Reflective</i> (TRF) tramite "
|
||
"<font name='Courier'>scipy.optimize.curve_fit</font>. "
|
||
f"T_inf = 22.99 °C è la media ponderata sul tempo dell'intera acquisizione "
|
||
"(calcolata con la regola dei trapezi).", s["body"]))
|
||
story.append(Spacer(1, 0.4*cm))
|
||
|
||
# ── SEZIONI INDUTTORI ────────────────────────────────────────────────────────
|
||
for i, ind in enumerate(induttori, 1):
|
||
story += section_header(f"{i + 1}. {ind['nome']}", s["h1"])
|
||
|
||
# Campionamento
|
||
story.append(Paragraph("Intervallo di campionamento", s["h2"]))
|
||
rows = [["Parametro", "Valore"],
|
||
["Inizio intervallo", f"{ind['t0']} s"],
|
||
["Fine intervallo", f"{ind['t_end']} s"],
|
||
["Numero di punti", ind["n_punti"]]]
|
||
if ind["zona_exc"]:
|
||
rows.append(["Zona esclusa dal fit (σ → ∞)", ind["zona_exc"]])
|
||
story.append(Table(rows, colWidths=[8*cm, CONTENT_W - 8*cm],
|
||
style=TABLE_STD))
|
||
story.append(Spacer(1, 0.3*cm))
|
||
|
||
# Equazione
|
||
story.append(Paragraph("Equazione del modello", s["h2"]))
|
||
story.append(Paragraph(ind["equazione"], s["eq"]))
|
||
story.append(Spacer(1, 0.2*cm))
|
||
story.append(Paragraph("Parametri stimati dalla regressione:", s["body"]))
|
||
story.append(Table([
|
||
["Parametro", "Valore stimato", "Incertezza (1σ)", "Note"],
|
||
["A", f"{ind['A']} °C", f"± {ind['A_err']} °C", "Ampiezza"],
|
||
["τ", f"{ind['tau']} s", f"± {ind['tau_err']} s","Costante di tempo"],
|
||
["R²", ind["r2"], "—", "Qualità del fit"],
|
||
], colWidths=[2.5*cm, 4.5*cm, 4*cm, CONTENT_W - 11*cm],
|
||
style=TABLE_STD))
|
||
story.append(Spacer(1, 0.3*cm))
|
||
|
||
# Grafico
|
||
if ind["png"] and Path(ind["png"]).exists():
|
||
story.append(Paragraph("Grafico del fit", s["h2"]))
|
||
story.append(KeepTogether([
|
||
Image(str(ind["png"]), width=CONTENT_W, height=CONTENT_W * 0.6),
|
||
Paragraph(
|
||
f"Figura {i}: fit esponenziale — {ind['nome']} "
|
||
f"[{ind['intervallo']}]", s["caption"]),
|
||
]))
|
||
story.append(Spacer(1, 0.5*cm))
|
||
|
||
# ── RIEPILOGO ────────────────────────────────────────────────────────────────
|
||
story.append(PageBreak())
|
||
story += section_header(f"{len(induttori) + 2}. Riepilogo comparativo", s["h1"])
|
||
story.append(Paragraph(
|
||
"Tabella comparativa dei parametri ottimali ottenuti dalla regressione "
|
||
"non lineare per ciascun induttore analizzato.", s["body"]))
|
||
story.append(Spacer(1, 0.2*cm))
|
||
|
||
summary = [["Induttore", "Intervallo [s]", "A [°C]", "τ [s]", "R²"]]
|
||
for ind in induttori:
|
||
summary.append([ind["nome"], ind["intervallo"],
|
||
ind["A"], ind["tau"], ind["r2"]])
|
||
story.append(Table(summary,
|
||
colWidths=[3.5*cm, 4.5*cm, 3*cm, 3*cm, CONTENT_W - 14*cm],
|
||
style=TABLE_DARK))
|
||
story.append(Spacer(1, 0.5*cm))
|
||
story.append(Paragraph(
|
||
"<b>Note:</b> tutti i fit usano T_inf = 22.99 °C fissa. "
|
||
"R² calcolato escludendo i punti con peso nullo.", s["body"]))
|
||
|
||
doc.build(story, canvasmaker=ProfessionalCanvas)
|
||
print(f"report_completo.pdf generato ({len(induttori)} induttori).")
|
||
|
||
|
||
# ─── Entry point ──────────────────────────────────────────────────────────────
|
||
if __name__ == "__main__":
|
||
induttori = collect_induttori()
|
||
if not induttori:
|
||
print("Nessun report*.md trovato nelle cartelle induttore*/.")
|
||
else:
|
||
generate_tex(induttori)
|
||
generate_pdf(induttori)
|