diff --git a/README.md b/README.md index 2298636..e2e8ba9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Due pezzi, stesso repo: | Servizio | Stato | Funzione | |---|---|---| -| `mcp-docugen` | Implementato, 92 test verde, deploy Docker via gateway Caddy (porta 8090), **8 tool MCP** esposti (CRUD template + `document_generate` + `document_to_pdf` + `document_to_docx`), template seed versionati, CSS Tielogic iniettato inline, render server-side **PDF** via Chromium/Playwright e **DOCX** via Pandoc con reference `tielogic-reference.docx` | Genera Markdown formale da template + LLM (OpenRouter) e converte in PDF o Word. Vedi [`docs/mcp-docugen-design.md`](docs/mcp-docugen-design.md) + [`docs/mcp-docugen-implementation.md`](docs/mcp-docugen-implementation.md). | +| `mcp-docugen` | Implementato, 101 test verde, deploy Docker via gateway Caddy (porta 8090), **8 tool MCP** (CRUD template + `document_generate` + `document_to_pdf` + `document_to_docx`), template seed versionati, CSS Tielogic iniettato inline, render server-side **PDF** via Chromium/Playwright e **DOCX** via Pandoc con preprocessor che riscrive HTML/CSS Tielogic in Markdown nativo + reference `tielogic-reference.docx` | Genera Markdown formale da template + LLM (OpenRouter) e converte in PDF o Word. Vedi [`docs/mcp-docugen-design.md`](docs/mcp-docugen-design.md) + [`docs/mcp-docugen-implementation.md`](docs/mcp-docugen-implementation.md). | | `mcp-convert` | Da progettare | Conversione Markdown → PDF / DOCX / HTML (pandoc/typst backend). | | `mcp-inbox` | Da progettare | Ingest da Telegram (+ STT opzionale via Whisper) verso draft inbox consumati da Claude Code desktop. | @@ -96,7 +96,12 @@ Conversione Markdown→PDF: tre strade, in ordine di comodità. Il CSS Tielogic non viene mai referenziato come path esterno nel Markdown prodotto dal servizio: il `Renderer` lo legge da `themes/tielogic.css` (copiato nell'immagine Docker in `/app/themes/`) e lo inietta come blocco `", re.DOTALL | re.IGNORECASE) + + +def _strip_style_blocks(text: str) -> str: + return _STYLE_BLOCK_RE.sub("", text) + + +def _strip_frontmatter(text: str) -> str: + if not text.startswith(_FRONTMATTER_DELIM): + return text + end_marker = f"\n{_FRONTMATTER_DELIM}\n" + idx = text.find(end_marker, len(_FRONTMATTER_DELIM)) + if idx == -1: + return text + return text[idx + len(end_marker) :].lstrip() + + +def _text(el: Tag | None) -> str: + return el.get_text(" ", strip=True) if el is not None else "" + + +def _info_col_lines(col: Tag) -> list[str]: + """Extract the rows of an info-col block (FORNITORE/CLIENTE), skipping + the label (used as table header) and bolding the company name.""" + lines: list[str] = [] + for child in col.find_all("div", recursive=False): + classes = set(child.get("class") or []) + if "info-label" in classes: + continue + txt = child.get_text(" ", strip=True) + if not txt: + continue + if "info-name" in classes: + lines.append(f"**{txt}**") + else: + lines.append(txt) + return lines + + +def _convert_cover(soup: BeautifulSoup) -> None: + cover = soup.find("div", class_="cover") + if not isinstance(cover, Tag): + return + + brand = _text(cover.find(class_="brand")) + tagline = _text(cover.find(class_="brand-tagline")) + title = _text(cover.find(class_="doc-title")) + product = _text(cover.find(class_="doc-product")) + ref = _text(cover.find(class_="doc-ref")) + validity = _text(cover.find(class_="doc-validity")) + + info_box = cover.find(class_="info-box") + info_cols = ( + info_box.find_all("div", class_="info-col") if isinstance(info_box, Tag) else [] + ) + + blocks: list[str] = [] + if brand: + blocks.append(f"# {brand}") + if tagline: + blocks.append(f"*{tagline}*") + blocks.append("---") + if title: + blocks.append(f"## {title}") + if product: + blocks.append(f"**{product}**") + if ref: + blocks.append(ref) + + if len(info_cols) == 2: + col_a, col_b = info_cols + label_a = _text(col_a.find(class_="info-label")) or "FORNITORE" + label_b = _text(col_b.find(class_="info-label")) or "CLIENTE" + rows_a = _info_col_lines(col_a) + rows_b = _info_col_lines(col_b) + height = max(len(rows_a), len(rows_b)) + rows_a += [""] * (height - len(rows_a)) + rows_b += [""] * (height - len(rows_b)) + + table_lines = [ + f"| **{label_a}** | **{label_b}** |", + "|---|---|", + ] + for a, b in zip(rows_a, rows_b): + table_lines.append(f"| {a} | {b} |") + blocks.append("\n".join(table_lines)) + + if validity: + blocks.append(f"*{validity}*") + + replacement = "\n\n".join(blocks) + "\n\n\\newpage\n" + cover.replace_with(BeautifulSoup(replacement, "html.parser")) + + +def _convert_acceptance(soup: BeautifulSoup) -> None: + acceptance = soup.find("div", class_="acceptance") + if not isinstance(acceptance, Tag): + return + + title_el = acceptance.find(class_="acceptance-title") + intro_el = acceptance.find(class_="acceptance-intro") + sig_grid = acceptance.find(class_="signature-grid") + place_date = acceptance.find(class_="place-date") + + title = _text(title_el) or "ACCETTAZIONE" + intro = _text(intro_el) + + blocks = [f"## {title}"] + if intro: + blocks.append(intro) + + if isinstance(sig_grid, Tag): + cols = sig_grid.find_all("div", class_="sig-col") + if len(cols) == 2: + party_a = _text(cols[0].find(class_="sig-party")) + party_b = _text(cols[1].find(class_="sig-party")) + line_a = _text(cols[0].find(class_="sig-line")) or "Firma e timbro" + line_b = _text(cols[1].find(class_="sig-line")) or "Firma e timbro" + blocks.append( + "\n".join( + [ + f"| **{party_a}** | **{party_b}** |", + "|---|---|", + "| _____________________ | _____________________ |", + f"| {line_a} | {line_b} |", + ] + ) + ) + + if isinstance(place_date, Tag): + blocks.append(_text(place_date)) + + replacement = "\n\n".join(blocks) + acceptance.replace_with(BeautifulSoup(replacement, "html.parser")) + + +def _convert_status_cards(soup: BeautifulSoup) -> None: + for card in soup.find_all("div", class_="status-card"): + if not isinstance(card, Tag): + continue + name_el = card.find(class_="name") + name = _text(name_el) + # remaining text (sibling divs after the name) + body_parts: list[str] = [] + for child in card.find_all("div", recursive=False): + if child is name_el: + continue + txt = child.get_text(" ", strip=True) + if txt: + body_parts.append(txt) + body = " ".join(body_parts) + block = f"**{name}** — {body}" if body else f"**{name}**" + card.replace_with(BeautifulSoup(block, "html.parser")) + + +def _convert_badges(soup: BeautifulSoup) -> None: + for span in soup.find_all("span", class_="badge"): + if not isinstance(span, Tag): + continue + txt = span.get_text(" ", strip=True) + span.replace_with(f"**{txt}**" if txt else "") + + +def _convert_page_breaks(soup: BeautifulSoup) -> None: + for el in soup.find_all("div", class_="page-break"): + if not isinstance(el, Tag): + continue + el.replace_with(BeautifulSoup("\n\n\\newpage\n\n", "html.parser")) + + +def _convert_financial_tables(soup: BeautifulSoup) -> None: + """Rewrite `
| Voce | Importo |
|---|---|
| Setup | € 3.500,00 |
| TOTALE SETUP | € 3.500,00 |