feat(mcp-docugen): output Word (.docx) via Pandoc con reference Tielogic
Aggiunge la generazione di documenti Word coerenti con l'identità
visiva Tielogic, in parallelo al render PDF già esistente. Il flusso
completo è ora `bullet input → Markdown formattato → PDF e/o DOCX`
in una singola chiamata MCP.
- docx_renderer.py: subprocess Pandoc che legge il Markdown da stdin,
emette il binario .docx su stdout. Strippa il YAML frontmatter e i
blocchi `<style>` (presenti per il PDF, irrilevanti in DOCX) prima
della conversione.
- mcp_tools.py: nuovo tool `document_to_docx(markdown)` che ritorna
`{docx_b64, size_bytes}`; `document_generate` esteso con
`output_format ∈ {md, pdf, docx, all}`. La firma di
`build_mcp_server` accetta ora `docx_reference_path` opzionale.
- config.py: `Settings.docx_reference_path` (default
/app/themes/tielogic-reference.docx).
- main.py: passa la nuova setting a `build_mcp_server`.
- mcp-docugen.Dockerfile: installazione di pandoc accanto alle libs
Chromium.
- themes/tielogic-reference.docx: reference Word (10 KB) con stili
Tielogic — heading colors blu/dark, font Inter, dimensioni allineate
al CSS web. Generato da `scripts/build-reference-docx.py` che parte
dal reference.docx di default di Pandoc e riscrive `word/styles.xml`
con regex sui blocchi `<w:style>`. Pandoc lo applica in automatico
agli output DOCX prodotti dal servizio.
- 9 nuovi test unit per docx_renderer (strip frontmatter/style,
preprocess combinato, error empty input, smoke skippato in
ambienti senza Pandoc): 92 test totali.
Smoke E2E via MCP: una sola chiamata `document_generate` con
`output_format=all` produce MD (14 KB), PDF (137 KB, 4 pagine A4) e
DOCX (12.7 KB) coerenti tra loro.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_docugen.docx_renderer import (
|
||||
DocxRenderError,
|
||||
_preprocess,
|
||||
_strip_frontmatter,
|
||||
_strip_style_blocks,
|
||||
render_markdown_to_docx,
|
||||
)
|
||||
|
||||
|
||||
def test_strip_style_blocks_removes_simple_block():
|
||||
md = "Pre\n<style>h1 { color: red; }</style>\nPost"
|
||||
assert "<style>" not in _strip_style_blocks(md)
|
||||
assert "color: red" not in _strip_style_blocks(md)
|
||||
|
||||
|
||||
def test_strip_style_blocks_removes_multiline_block_with_attrs():
|
||||
md = (
|
||||
"Before\n"
|
||||
"<style type=\"text/css\">\n"
|
||||
" body { font-family: Inter; }\n"
|
||||
" h1 { color: blue; }\n"
|
||||
"</style>\n"
|
||||
"After"
|
||||
)
|
||||
cleaned = _strip_style_blocks(md)
|
||||
assert "<style" not in cleaned
|
||||
assert "Inter" not in cleaned
|
||||
assert "Before" in cleaned and "After" in cleaned
|
||||
|
||||
|
||||
def test_strip_style_blocks_no_style_is_noop():
|
||||
md = "# Just markdown\n\nNo style here."
|
||||
assert _strip_style_blocks(md) == md
|
||||
|
||||
|
||||
def test_strip_frontmatter_removes_block():
|
||||
md = "---\nfoo: bar\npdf_options:\n format: A4\n---\n\n# Body\n"
|
||||
out = _strip_frontmatter(md)
|
||||
assert out.startswith("# Body")
|
||||
assert "pdf_options" not in out
|
||||
|
||||
|
||||
def test_strip_frontmatter_no_frontmatter_returns_input_unchanged():
|
||||
md = "# Body only\n\nText."
|
||||
assert _strip_frontmatter(md) == md
|
||||
|
||||
|
||||
def test_strip_frontmatter_unclosed_returns_input_unchanged():
|
||||
md = "---\nfoo: bar\nno closing delim"
|
||||
assert _strip_frontmatter(md) == md
|
||||
|
||||
|
||||
def test_preprocess_strips_both_frontmatter_and_style():
|
||||
md = (
|
||||
"---\nfoo: bar\n---\n\n"
|
||||
"<style>body { color: red; }</style>\n\n"
|
||||
"# Body\n\nContent.\n"
|
||||
)
|
||||
out = _preprocess(md)
|
||||
assert "foo: bar" not in out
|
||||
assert "<style>" not in out
|
||||
assert "# Body" in out
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
shutil.which("pandoc") is None,
|
||||
reason="pandoc binary not available; runs in container or CI with pandoc installed",
|
||||
)
|
||||
async def test_render_markdown_to_docx_roundtrip():
|
||||
md = (
|
||||
"---\nfoo: bar\n---\n\n"
|
||||
"<style>h1 { color: red; }</style>\n\n"
|
||||
"# Hello\n\n"
|
||||
"| A | B |\n|---|---|\n| 1 | 2 |\n\n"
|
||||
"**bold** and *italic*.\n"
|
||||
)
|
||||
result = await render_markdown_to_docx(md)
|
||||
# DOCX is a ZIP archive; signature: PK\x03\x04
|
||||
assert result.docx_bytes.startswith(b"PK\x03\x04")
|
||||
assert result.size_bytes > 1000
|
||||
|
||||
|
||||
async def test_render_empty_markdown_raises():
|
||||
with pytest.raises(DocxRenderError):
|
||||
await render_markdown_to_docx("")
|
||||
with pytest.raises(DocxRenderError):
|
||||
await render_markdown_to_docx(" \n\n ")
|
||||
|
||||
|
||||
async def test_render_only_frontmatter_and_style_raises():
|
||||
md = "---\nfoo: bar\n---\n\n<style>h1{}</style>\n\n \n"
|
||||
with pytest.raises(DocxRenderError):
|
||||
await render_markdown_to_docx(md)
|
||||
Reference in New Issue
Block a user