From b32669caa7b68c249ae8dd549f5e897044a8b7d7 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 25 Apr 2026 15:35:41 +0200 Subject: [PATCH] feat(mcp-docugen): Markdown autocontenuto con CSS Tielogic iniettato inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problema: i template puntavano a un path host hardcoded (stylesheet: /home/adriano/.../themes/tielogic.css), quindi il file .md generato non era portabile — su un'altra macchina md-to-pdf non trovava il CSS e produceva PDF senza stile. Soluzione: il Renderer legge il CSS da Settings.inline_stylesheet_path (default /app/themes/tielogic.css nel container) e lo inietta come blocco subito dopo il frontmatter YAML del Markdown restituito dall'LLM. Il file .md risultante è autocontenuto e portabile. - renderer.py: nuovo arg inline_stylesheet_path + funzione _inject_inline_stylesheet (idempotente, gestisce Markdown senza frontmatter, no-op se CSS vuoto) - config.py: Settings.inline_stylesheet_path: Path | None - main.py: passa il path al Renderer - mcp-docugen.Dockerfile: COPY themes ./themes nello stage builder per trasportare /app/themes/tielogic.css nell'immagine runtime - templates_seed/{offerta,report-analisi}/template.md: rimossa la riga `stylesheet:` dal frontmatter di output + regola tassativa che vieta all'LLM di emettere blocchi \n\n{rest.lstrip()}" + ) + + if in_place: + md_path.write_text(bundled, encoding="utf-8") + return md_path + + out_path = md_path.with_name(md_path.stem + ".bundled.md") + out_path.write_text(bundled, encoding="utf-8") + return out_path + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("md_file", type=Path) + parser.add_argument("--css", type=Path, default=DEFAULT_CSS) + parser.add_argument("--in-place", action="store_true") + args = parser.parse_args() + + if not args.md_file.is_file(): + raise SystemExit(f"file non trovato: {args.md_file}") + if not args.css.is_file(): + raise SystemExit(f"CSS non trovato: {args.css}") + + out = bundle(args.md_file, args.css, args.in_place) + print(f"OK: {out}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/services/mcp-docugen/src/mcp_docugen/config.py b/services/mcp-docugen/src/mcp_docugen/config.py index 54bbb13..d48405e 100644 --- a/services/mcp-docugen/src/mcp_docugen/config.py +++ b/services/mcp-docugen/src/mcp_docugen/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): public_base_url: str = Field(...) data_dir: Path = Path("/data") templates_seed_dir: Path = Path("/app/services/mcp-docugen/templates_seed") + inline_stylesheet_path: Path | None = Path("/app/themes/tielogic.css") asset_ttl_days: int = 30 max_image_size_mb: int = 10 llm_timeout_seconds: int = 60 diff --git a/services/mcp-docugen/src/mcp_docugen/main.py b/services/mcp-docugen/src/mcp_docugen/main.py index d6a1598..b032079 100644 --- a/services/mcp-docugen/src/mcp_docugen/main.py +++ b/services/mcp-docugen/src/mcp_docugen/main.py @@ -48,6 +48,7 @@ async def build_app(settings: Settings | None = None) -> FastAPI: default_model=settings.llm_model_default, asset_ttl_days=settings.asset_ttl_days, max_image_size_mb=settings.max_image_size_mb, + inline_stylesheet_path=settings.inline_stylesheet_path, ) mcp = build_mcp_server(template_store, renderer) diff --git a/services/mcp-docugen/src/mcp_docugen/renderer.py b/services/mcp-docugen/src/mcp_docugen/renderer.py index 637052d..031a0c7 100644 --- a/services/mcp-docugen/src/mcp_docugen/renderer.py +++ b/services/mcp-docugen/src/mcp_docugen/renderer.py @@ -4,6 +4,7 @@ import base64 import binascii import re import uuid +from pathlib import Path import aiofiles from pydantic import ValidationError @@ -52,6 +53,33 @@ class AssetStorageError(RendererError): _ASSET_TEMPLATE_REF_RE = re.compile(r"\.\/assets\/([A-Za-z0-9._-]+)") +_FRONTMATTER_DELIM = "---" + + +def _inject_inline_stylesheet(markdown: str, css: str) -> str: + """Insert a `" + + if markdown.startswith(_FRONTMATTER_DELIM + "\n") or markdown.startswith( + _FRONTMATTER_DELIM + "\r\n" + ): + # Find the closing delimiter on its own line. + end_marker = f"\n{_FRONTMATTER_DELIM}\n" + idx = markdown.find(end_marker, len(_FRONTMATTER_DELIM)) + if idx != -1: + insert_at = idx + len(end_marker) + return markdown[:insert_at] + "\n" + style_block + "\n\n" + markdown[ + insert_at: + ] + + # No frontmatter: prepend the style block. + return style_block + "\n\n" + markdown + + class Renderer: def __init__( self, @@ -62,6 +90,7 @@ class Renderer: default_model: str, asset_ttl_days: int, max_image_size_mb: int, + inline_stylesheet_path: Path | None = None, ) -> None: self.template_store = template_store self.generation_store = generation_store @@ -70,6 +99,7 @@ class Renderer: self.default_model = default_model self.asset_ttl_days = asset_ttl_days self.max_image_size_bytes = max_image_size_mb * 1024 * 1024 + self.inline_stylesheet_path = inline_stylesheet_path async def generate( self, @@ -126,16 +156,19 @@ class Renderer: expires_at = None if ephemeral_urls: - from pathlib import Path - info = await self.generation_store.get_ephemeral_asset( gen_id, Path(ephemeral_urls[0]).name ) expires_at = info.expires_at if info else None + markdown_out = response.text + if self.inline_stylesheet_path and self.inline_stylesheet_path.is_file(): + css = self.inline_stylesheet_path.read_text(encoding="utf-8") + markdown_out = _inject_inline_stylesheet(markdown_out, css) + return GenerationResult( generation_id=gen_id, - markdown=response.text, + markdown=markdown_out, model=response.model, tokens=TokenUsage(input=response.tokens_in, output=response.tokens_out), cost_usd=response.cost_usd, diff --git a/services/mcp-docugen/templates_seed/offerta/template.md b/services/mcp-docugen/templates_seed/offerta/template.md index 33f4427..92dedf1 100644 --- a/services/mcp-docugen/templates_seed/offerta/template.md +++ b/services/mcp-docugen/templates_seed/offerta/template.md @@ -39,7 +39,6 @@ Italiano formale, terza persona impersonale, tono professionale ma asciutto. Nie ``` --- -stylesheet: /home/adriano/Documenti/Git_XYZ/ArcaSuite/themes/tielogic.css pdf_options: format: A4 margin: @@ -54,6 +53,10 @@ pdf_options: --- ``` +**Niente** `stylesheet:` né `css:` nel frontmatter: il sistema inietta automaticamente il foglio di stile come `` di tua iniziativa né tag ``. Il foglio di stile è gestito esclusivamente dal sistema in fase di post-processing. Aggiungere CSS personalizzato genera conflitti di cascade e rovina il render. + ## Cover (pagina 1, HTML) ``` diff --git a/services/mcp-docugen/templates_seed/report-analisi/template.md b/services/mcp-docugen/templates_seed/report-analisi/template.md index ff69030..f94714f 100644 --- a/services/mcp-docugen/templates_seed/report-analisi/template.md +++ b/services/mcp-docugen/templates_seed/report-analisi/template.md @@ -51,7 +51,6 @@ Il documento DEVE iniziare con questo YAML frontmatter compatibile `md-to-pdf` ( ``` --- -stylesheet: /home/adriano/Documenti/Git_XYZ/ArcaSuite/themes/tielogic.css pdf_options: format: A4 margin: @@ -66,6 +65,10 @@ pdf_options: --- ``` +**Niente** `stylesheet:` né `css:` nel frontmatter: il sistema inietta automaticamente il foglio di stile come `` di tua iniziativa né tag ``. Il foglio di stile è gestito esclusivamente dal sistema in fase di post-processing. Aggiungere CSS personalizzato genera conflitti di cascade e rovina il render. + La cover (sezione 1) è impostata in CSS con `margin: -22mm` per andare bordo-a-bordo nonostante il margine top di 18mm; header/footer NON appaiono in cover (Chromium li disegna sopra il contenuto solo se questo non occupa l'intera area; la cover è pagina 1 con `page-break-after: always`). I margini interni del corpo sono gestiti dal CSS (`@page` e `.cover`). Margini PDF a 0 sui lati per consentire alla cover scura di andare bordo-a-bordo. diff --git a/services/mcp-docugen/tests/unit/test_renderer.py b/services/mcp-docugen/tests/unit/test_renderer.py index cfdd644..399030e 100644 --- a/services/mcp-docugen/tests/unit/test_renderer.py +++ b/services/mcp-docugen/tests/unit/test_renderer.py @@ -272,3 +272,71 @@ async def test_generate_uses_frontmatter_model_override(tmp_path): template_name="x", content_md="c", variables={}, instructions=None ) assert llm.chat.await_args.kwargs["model"] == "openai/gpt-4o" + + +def test_inject_inline_stylesheet_after_frontmatter(): + from mcp_docugen.renderer import _inject_inline_stylesheet + + md = "---\ntitle: T\n---\n\n# Body\n" + out = _inject_inline_stylesheet(md, "body { color: red; }") + assert out.startswith("---\ntitle: T\n---\n") + assert "" in out + body_idx = out.find("# Body") + style_idx = out.find("") + + +def test_inject_inline_stylesheet_empty_css_is_noop(): + from mcp_docugen.renderer import _inject_inline_stylesheet + + md = "---\nx: 1\n---\nbody" + assert _inject_inline_stylesheet(md, "") == md + assert _inject_inline_stylesheet(md, " \n") == md + + +async def test_generate_injects_inline_stylesheet_when_configured(tmp_path): + css_path = tmp_path / "tielogic.css" + css_path.write_text("body { font-family: Inter; }", encoding="utf-8") + + template_store = TemplateStore(base_dir=tmp_path / "templates") + generation_store = GenerationStore( + db_path=tmp_path / "gen.db", + generated_dir=tmp_path / "generated", + ) + await generation_store.init() + + llm = AsyncMock(spec=OpenRouterClient) + llm.chat.return_value = LLMResponse( + text="---\ntitle: T\n---\n\n# Body\n", + model="anthropic/claude-sonnet-4", + tokens_in=1, + tokens_out=1, + cost_usd=0.0, + latency_ms=10, + ) + renderer = Renderer( + template_store=template_store, + generation_store=generation_store, + llm=llm, + public_base_url="https://mcp.example.com", + default_model="anthropic/claude-sonnet-4", + asset_ttl_days=30, + max_image_size_mb=10, + inline_stylesheet_path=css_path, + ) + fm = TemplateFrontmatter(name="t", description="d") + await template_store.create(name="t", frontmatter=fm, body="b") + + result = await renderer.generate( + template_name="t", content_md="c", variables={}, instructions=None + ) + assert "