725190010c
Aggiunge la possibilità di convertire un documento Markdown in PDF
direttamente lato server, senza richiedere al chiamante di avere
md-to-pdf, pandoc o altri tool sull'host. Il PDF è restituito come
stringa base64 nella risposta JSON-RPC, pronto a essere salvato,
allegato o spedito al cliente.
- pdf_renderer.py: nuovo modulo che parsea il frontmatter YAML del
Markdown (incluso il blocco pdf_options stile Puppeteer/md-to-pdf),
rende il body in HTML via markdown-it-py (supporta tabelle e
HTML inline) e produce il PDF tramite Chromium headless gestito
da Playwright. Le pdf_options camelCase (printBackground,
displayHeaderFooter, headerTemplate, ...) vengono tradotte negli
argomenti snake_case di page.pdf().
- mcp_tools.py: nuovo tool `document_to_pdf(markdown)` che ritorna
`{pdf_b64, size_bytes}`; `document_generate` esteso con il
parametro `output_format ∈ {md, pdf, both}` per emettere il PDF
contestualmente alla generazione del Markdown.
- pyproject.toml + uv.lock: aggiunte le dipendenze playwright>=1.48
e markdown-it-py[plugins]>=3.0.
- mcp-docugen.Dockerfile: nuova fase di runtime che installa le
librerie native richieste da Chromium (libnss3, libgbm1, ecc.) e
scarica il binario Chromium di Playwright in /opt/ms-playwright.
- 7 nuovi test unit (83 totali) che coprono lo split del frontmatter,
il rendering Markdown→HTML con tabelle, la traduzione delle
pdf_options camelCase→snake_case e l'errore su YAML invalido. Il
test E2E che richiede Chromium è marcato skip in unit; lo smoke
via MCP conferma generazione PDF da 134 KB / 4 pagine.
README aggiornato con le tre strade di conversione (server-side,
client-side, bundling) e la stima del nuovo costo immagine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
2.6 KiB
Python
87 lines
2.6 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from mcp_docugen.pdf_renderer import (
|
|
PdfRenderError,
|
|
_md_to_html,
|
|
_normalize_pdf_options,
|
|
_split_frontmatter,
|
|
)
|
|
|
|
|
|
def test_split_frontmatter_extracts_yaml_dict():
|
|
md = "---\ntitle: Foo\npdf_options:\n format: A4\n---\n\n# Body\n"
|
|
fm, body = _split_frontmatter(md)
|
|
assert fm == {"title": "Foo", "pdf_options": {"format": "A4"}}
|
|
assert body.lstrip().startswith("# Body")
|
|
|
|
|
|
def test_split_frontmatter_no_frontmatter_returns_empty_dict():
|
|
md = "# Just body\nNo YAML here.\n"
|
|
fm, body = _split_frontmatter(md)
|
|
assert fm == {}
|
|
assert body == md
|
|
|
|
|
|
def test_split_frontmatter_invalid_yaml_raises():
|
|
md = "---\ntitle: : : broken\n---\nbody"
|
|
with pytest.raises(PdfRenderError):
|
|
_split_frontmatter(md)
|
|
|
|
|
|
def test_md_to_html_renders_table_and_html_passthrough():
|
|
body = "| A | B |\n|---|---|\n| 1 | 2 |\n\n<div class=\"x\">raw</div>\n"
|
|
html = _md_to_html(body)
|
|
assert "<table>" in html
|
|
assert "<th>A</th>" in html
|
|
assert '<div class="x">raw</div>' in html
|
|
|
|
|
|
def test_normalize_pdf_options_translates_camelcase_to_snake():
|
|
src = {
|
|
"format": "A4",
|
|
"printBackground": True,
|
|
"displayHeaderFooter": True,
|
|
"headerTemplate": "<div>H</div>",
|
|
"footerTemplate": "<div>F</div>",
|
|
"margin": {"top": "18mm", "bottom": "18mm"},
|
|
"landscape": False,
|
|
"scale": 0.95,
|
|
}
|
|
out = _normalize_pdf_options(src)
|
|
assert out["format"] == "A4"
|
|
assert out["print_background"] is True
|
|
assert out["display_header_footer"] is True
|
|
assert out["header_template"] == "<div>H</div>"
|
|
assert out["footer_template"] == "<div>F</div>"
|
|
assert out["margin"]["top"] == "18mm"
|
|
assert out["landscape"] is False
|
|
assert out["scale"] == pytest.approx(0.95)
|
|
|
|
|
|
def test_normalize_pdf_options_drops_unknown_keys():
|
|
src = {"format": "A4", "weird": 123, "viewportWidth": 1200}
|
|
out = _normalize_pdf_options(src)
|
|
assert out == {"format": "A4"}
|
|
|
|
|
|
def test_normalize_pdf_options_handles_non_dict():
|
|
assert _normalize_pdf_options(None) == {}
|
|
assert _normalize_pdf_options("not a dict") == {}
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
True, reason="requires Chromium binary; runs only in container/CI with Playwright installed"
|
|
)
|
|
async def test_render_markdown_to_pdf_smoke():
|
|
from mcp_docugen.pdf_renderer import render_markdown_to_pdf
|
|
|
|
md = (
|
|
"---\npdf_options:\n format: A4\n---\n\n"
|
|
"<style>h1 { color: red; }</style>\n\n# Hello\n\nBody text.\n"
|
|
)
|
|
result = await render_markdown_to_pdf(md)
|
|
assert result.pdf_bytes.startswith(b"%PDF-")
|
|
assert result.size_bytes > 1000
|