office-worker-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- office_worker_mcp-0.1.0/LICENSE +21 -0
- office_worker_mcp-0.1.0/NOTICE +13 -0
- office_worker_mcp-0.1.0/PKG-INFO +111 -0
- office_worker_mcp-0.1.0/README.md +78 -0
- office_worker_mcp-0.1.0/pyproject.toml +47 -0
- office_worker_mcp-0.1.0/setup.cfg +4 -0
- office_worker_mcp-0.1.0/src/office_worker/__init__.py +5 -0
- office_worker_mcp-0.1.0/src/office_worker/cli.py +55 -0
- office_worker_mcp-0.1.0/src/office_worker/core/__init__.py +11 -0
- office_worker_mcp-0.1.0/src/office_worker/core/excel.py +55 -0
- office_worker_mcp-0.1.0/src/office_worker/core/pdf.py +44 -0
- office_worker_mcp-0.1.0/src/office_worker/core/slides.py +67 -0
- office_worker_mcp-0.1.0/src/office_worker/core/templates.py +69 -0
- office_worker_mcp-0.1.0/src/office_worker/core/themes.py +49 -0
- office_worker_mcp-0.1.0/src/office_worker/core/word.py +60 -0
- office_worker_mcp-0.1.0/src/office_worker/mcp_server.py +100 -0
- office_worker_mcp-0.1.0/src/office_worker_mcp.egg-info/PKG-INFO +111 -0
- office_worker_mcp-0.1.0/src/office_worker_mcp.egg-info/SOURCES.txt +22 -0
- office_worker_mcp-0.1.0/src/office_worker_mcp.egg-info/dependency_links.txt +1 -0
- office_worker_mcp-0.1.0/src/office_worker_mcp.egg-info/entry_points.txt +6 -0
- office_worker_mcp-0.1.0/src/office_worker_mcp.egg-info/requires.txt +19 -0
- office_worker_mcp-0.1.0/src/office_worker_mcp.egg-info/top_level.txt +1 -0
- office_worker_mcp-0.1.0/tests/test_core.py +36 -0
- office_worker_mcp-0.1.0/tests/test_e2e_mcp.py +72 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julio Cardozo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Acknowledgements
|
|
2
|
+
|
|
3
|
+
`office-worker-mcp` was designed and built independently by Julio Cardozo.
|
|
4
|
+
|
|
5
|
+
During its development, the following MIT-licensed project was used as a **read-only
|
|
6
|
+
reference** for MCP server structure and tool design patterns (no code was copied
|
|
7
|
+
verbatim; it informed architectural decisions only):
|
|
8
|
+
|
|
9
|
+
- **gawirable/office-mcp** — https://github.com/gawirable/office-mcp (MIT License)
|
|
10
|
+
|
|
11
|
+
All other functionality (template engine with variables, corporate theme system,
|
|
12
|
+
PDF-as-input reading/extraction, editable PPTX generation, anti-loop guidance) is
|
|
13
|
+
original work of this repository.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: office-worker-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Office Worker — your agent's document clerk: Word, Excel, PowerPoint & PDF with real design (templates + themes + PDF input).
|
|
5
|
+
Author: Julio Cardozo
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,office,pdf,docx,xlsx,pptx,documents,agent,templates
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Office/Business
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
License-File: NOTICE
|
|
15
|
+
Requires-Dist: mcp[cli]<2,>=1.27
|
|
16
|
+
Requires-Dist: python-docx>=1.0
|
|
17
|
+
Requires-Dist: openpyxl>=3.1
|
|
18
|
+
Requires-Dist: python-pptx>=1.0
|
|
19
|
+
Requires-Dist: weasyprint>=60
|
|
20
|
+
Requires-Dist: jinja2>=3.1
|
|
21
|
+
Requires-Dist: PyMuPDF>=1.24
|
|
22
|
+
Requires-Dist: pdfplumber>=0.11
|
|
23
|
+
Requires-Dist: pypdf>=4
|
|
24
|
+
Provides-Extra: pptx
|
|
25
|
+
Requires-Dist: html-to-pptx>=0.3; extra == "pptx"
|
|
26
|
+
Requires-Dist: playwright>=1.40; extra == "pptx"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
30
|
+
Requires-Dist: html-to-pptx>=0.3; extra == "dev"
|
|
31
|
+
Requires-Dist: playwright>=1.40; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# The Office Worker — `office-worker-mcp`
|
|
35
|
+
|
|
36
|
+
> **Your agent's document clerk.** Genera documentos de oficina profesionales —
|
|
37
|
+
> **PDF, Word, Excel y PowerPoint** — desde datos + un tema corporativo, y lee PDFs
|
|
38
|
+
> (texto, tablas y formularios). Diseñado para agentes de IA (Hermes, Claude, Cursor)
|
|
39
|
+
> vía [MCP](https://modelcontextprotocol.io), 100% local-first.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pip install office-worker-mcp # instala librería + MCP server + CLI 'owi'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Conectalo a tu agente (ejemplo config MCP / stdio):
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{ "mcpServers": { "office-worker": { "command": "office-worker-mcp" } } }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Y listo: le pedís *"haceme una factura/informe/deck"* en lenguaje natural y tu agente
|
|
52
|
+
usa las tools del oficinista para producir el archivo bien diseñado.
|
|
53
|
+
|
|
54
|
+
## Por qué existe (diferenciadores)
|
|
55
|
+
|
|
56
|
+
Los MCPs de documentos existentes son CRUD genéricos (47+ tools que inflan el contexto)
|
|
57
|
+
y no cubren diseño ni PDF como entrada. El Office Worker hace lo contrario:
|
|
58
|
+
|
|
59
|
+
| | office-mcp / takos | **The Office Worker** |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Tools | ~47–80 CRUD genéricas | **8 poderosas** (~2K tok/turno vs ~6.4K) |
|
|
62
|
+
| Plantillas con variables | ❌ | ✅ Jinja (`{{ var }}`) |
|
|
63
|
+
| Temas corporativos (paleta/fuente) | ❌ parámetros sueltos | ✅ aplicable de una vez a todos los formatos |
|
|
64
|
+
| PDF como **input** (leer/tablas/formularios) | ❌ | ✅ PyMuPDF + pdfplumber + pypdf |
|
|
65
|
+
| PPTX **editable** (textos nativos, no imágenes) | parcial | ✅ html-to-pptx verificado |
|
|
66
|
+
| Guía anti-loop para agentes | ❌ | ✅ SKILL.md con reglas probadas en producción |
|
|
67
|
+
|
|
68
|
+
## Las 8 tools
|
|
69
|
+
|
|
70
|
+
| Tool | Qué hace |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `render_document` | PDF profesional desde plantilla HTML/Jinja + datos + tema (WeasyPrint) |
|
|
73
|
+
| `create_word` | `.docx` con h1/h2/párrafos/tablas, tema aplicado (python-docx) |
|
|
74
|
+
| `create_excel` | `.xlsx` multi-hoja con paleta y filas alternas (openpyxl) |
|
|
75
|
+
| `create_pptx` | `.pptx` **editable** desde slides declarativas (html-to-pptx + Playwright) |
|
|
76
|
+
| `read_pdf` | Extrae texto por página + metadatos de un PDF (input) |
|
|
77
|
+
| `pdf_extract_tables` | Tablas estructuradas de un PDF (pdfplumber) |
|
|
78
|
+
| `pdf_list_form_fields` | Campos de formulario AcroForm de un PDF (pypdf) |
|
|
79
|
+
| `list_themes` | Devuelve la paleta/fuente del tema activo como referencia de diseño |
|
|
80
|
+
|
|
81
|
+
## Uso rápido (sin agente, vía CLI o Python)
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from office_worker.core import create_word, create_excel, render_pdf
|
|
85
|
+
|
|
86
|
+
create_word("acta.docx", title="Acta", subtitle="Reunión", blocks=[
|
|
87
|
+
{"type":"h2","text":"Puntos"}, {"type":"p","text":"Decisión tomada"}])
|
|
88
|
+
|
|
89
|
+
create_excel("balance.xlsx", title="Balance", sheets=[{"name":"Resumen",
|
|
90
|
+
"headers":["Rubro","Monto"],"rows":[["Ingresos",100],["Gastos",40]]}])
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Requisitos locales
|
|
94
|
+
|
|
95
|
+
- Python ≥ 3.9 · WeasyPrint (necesita fontconfig/pango en Linux) · LibreOffice opcional.
|
|
96
|
+
- **PPTX editable** (opcional): `pip install office-worker-mcp[pptx]` + `playwright install chromium`.
|
|
97
|
+
Sin este extra, las tools de PDF/Word/Excel funcionan igual; solo `create_pptx` queda deshabilitada.
|
|
98
|
+
- Todo corre en local; sin cloud.
|
|
99
|
+
|
|
100
|
+
## Desarrollo & tests
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install -e ".[dev]"
|
|
104
|
+
pytest # test_core.py (núcleo) + test_e2e_mcp.py (MCP end-to-end, 8/8 tools)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
El test E2E levanta el servidor MCP real, hace handshake y ejecuta cada tool creando/leyendo archivos válidos en disco.
|
|
108
|
+
|
|
109
|
+
## Licencia
|
|
110
|
+
|
|
111
|
+
MIT — ver [LICENSE](LICENSE). Agradecimientos en [NOTICE](NOTICE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# The Office Worker — `office-worker-mcp`
|
|
2
|
+
|
|
3
|
+
> **Your agent's document clerk.** Genera documentos de oficina profesionales —
|
|
4
|
+
> **PDF, Word, Excel y PowerPoint** — desde datos + un tema corporativo, y lee PDFs
|
|
5
|
+
> (texto, tablas y formularios). Diseñado para agentes de IA (Hermes, Claude, Cursor)
|
|
6
|
+
> vía [MCP](https://modelcontextprotocol.io), 100% local-first.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
pip install office-worker-mcp # instala librería + MCP server + CLI 'owi'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Conectalo a tu agente (ejemplo config MCP / stdio):
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{ "mcpServers": { "office-worker": { "command": "office-worker-mcp" } } }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Y listo: le pedís *"haceme una factura/informe/deck"* en lenguaje natural y tu agente
|
|
19
|
+
usa las tools del oficinista para producir el archivo bien diseñado.
|
|
20
|
+
|
|
21
|
+
## Por qué existe (diferenciadores)
|
|
22
|
+
|
|
23
|
+
Los MCPs de documentos existentes son CRUD genéricos (47+ tools que inflan el contexto)
|
|
24
|
+
y no cubren diseño ni PDF como entrada. El Office Worker hace lo contrario:
|
|
25
|
+
|
|
26
|
+
| | office-mcp / takos | **The Office Worker** |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Tools | ~47–80 CRUD genéricas | **8 poderosas** (~2K tok/turno vs ~6.4K) |
|
|
29
|
+
| Plantillas con variables | ❌ | ✅ Jinja (`{{ var }}`) |
|
|
30
|
+
| Temas corporativos (paleta/fuente) | ❌ parámetros sueltos | ✅ aplicable de una vez a todos los formatos |
|
|
31
|
+
| PDF como **input** (leer/tablas/formularios) | ❌ | ✅ PyMuPDF + pdfplumber + pypdf |
|
|
32
|
+
| PPTX **editable** (textos nativos, no imágenes) | parcial | ✅ html-to-pptx verificado |
|
|
33
|
+
| Guía anti-loop para agentes | ❌ | ✅ SKILL.md con reglas probadas en producción |
|
|
34
|
+
|
|
35
|
+
## Las 8 tools
|
|
36
|
+
|
|
37
|
+
| Tool | Qué hace |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `render_document` | PDF profesional desde plantilla HTML/Jinja + datos + tema (WeasyPrint) |
|
|
40
|
+
| `create_word` | `.docx` con h1/h2/párrafos/tablas, tema aplicado (python-docx) |
|
|
41
|
+
| `create_excel` | `.xlsx` multi-hoja con paleta y filas alternas (openpyxl) |
|
|
42
|
+
| `create_pptx` | `.pptx` **editable** desde slides declarativas (html-to-pptx + Playwright) |
|
|
43
|
+
| `read_pdf` | Extrae texto por página + metadatos de un PDF (input) |
|
|
44
|
+
| `pdf_extract_tables` | Tablas estructuradas de un PDF (pdfplumber) |
|
|
45
|
+
| `pdf_list_form_fields` | Campos de formulario AcroForm de un PDF (pypdf) |
|
|
46
|
+
| `list_themes` | Devuelve la paleta/fuente del tema activo como referencia de diseño |
|
|
47
|
+
|
|
48
|
+
## Uso rápido (sin agente, vía CLI o Python)
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from office_worker.core import create_word, create_excel, render_pdf
|
|
52
|
+
|
|
53
|
+
create_word("acta.docx", title="Acta", subtitle="Reunión", blocks=[
|
|
54
|
+
{"type":"h2","text":"Puntos"}, {"type":"p","text":"Decisión tomada"}])
|
|
55
|
+
|
|
56
|
+
create_excel("balance.xlsx", title="Balance", sheets=[{"name":"Resumen",
|
|
57
|
+
"headers":["Rubro","Monto"],"rows":[["Ingresos",100],["Gastos",40]]}])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Requisitos locales
|
|
61
|
+
|
|
62
|
+
- Python ≥ 3.9 · WeasyPrint (necesita fontconfig/pango en Linux) · LibreOffice opcional.
|
|
63
|
+
- **PPTX editable** (opcional): `pip install office-worker-mcp[pptx]` + `playwright install chromium`.
|
|
64
|
+
Sin este extra, las tools de PDF/Word/Excel funcionan igual; solo `create_pptx` queda deshabilitada.
|
|
65
|
+
- Todo corre en local; sin cloud.
|
|
66
|
+
|
|
67
|
+
## Desarrollo & tests
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e ".[dev]"
|
|
71
|
+
pytest # test_core.py (núcleo) + test_e2e_mcp.py (MCP end-to-end, 8/8 tools)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
El test E2E levanta el servidor MCP real, hace handshake y ejecuta cada tool creando/leyendo archivos válidos en disco.
|
|
75
|
+
|
|
76
|
+
## Licencia
|
|
77
|
+
|
|
78
|
+
MIT — ver [LICENSE](LICENSE). Agradecimientos en [NOTICE](NOTICE).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "office-worker-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The Office Worker — your agent's document clerk: Word, Excel, PowerPoint & PDF with real design (templates + themes + PDF input)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Julio Cardozo" }]
|
|
13
|
+
keywords = ["mcp", "office", "pdf", "docx", "xlsx", "pptx", "documents", "agent", "templates"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Office/Business",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"mcp[cli]>=1.27,<2",
|
|
21
|
+
"python-docx>=1.0",
|
|
22
|
+
"openpyxl>=3.1",
|
|
23
|
+
"python-pptx>=1.0",
|
|
24
|
+
"weasyprint>=60",
|
|
25
|
+
"jinja2>=3.1",
|
|
26
|
+
"PyMuPDF>=1.24",
|
|
27
|
+
"pdfplumber>=0.11",
|
|
28
|
+
"pypdf>=4",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# PPTX editable requiere html-to-pptx + Playwright/Chromium (pesado, con navegador).
|
|
32
|
+
# Se declara como extra opcional para no forzar Chromium a quien solo usa PDF/Word/Excel.
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
pptx = ["html-to-pptx>=0.3", "playwright>=1.40"]
|
|
35
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "html-to-pptx>=0.3", "playwright>=1.40"]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
# Cara CLI (opcional) para no-tecnicos/scripts; el MCP es la cara principal para agentes IA.
|
|
39
|
+
owi = "office_worker.cli:main"
|
|
40
|
+
# Command del MCP server (stdio) — el que va en la config mcpServers del agente.
|
|
41
|
+
office-worker-mcp = "office_worker.mcp_server:main"
|
|
42
|
+
|
|
43
|
+
[project.entry-points."mcp.servers"]
|
|
44
|
+
office_worker = "office_worker.mcp_server:main"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Cara CLI mínima ('owi') para scripts/no-tecnicos. El MCP es la cara principal; esto es extra.
|
|
2
|
+
|
|
3
|
+
Uso:
|
|
4
|
+
owi pdf --out informe.pdf --title "Informe" --subtitle "Q3" [--theme aden] [--rows "A|1,B|2"]
|
|
5
|
+
owi word --out acta.docx --title "Acta" [--blocks-json '[{"type":"p","text":"hola"}]']
|
|
6
|
+
owi excel --out bal.xlsx --title "Balance" [--sheets-json '[{"name":"R","headers":[...],"rows":[...]}]']
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import argparse, json, os, sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _print(r): print(json.dumps(r, ensure_ascii=False))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
p = argparse.ArgumentParser(prog="owi", description="The Office Worker — CLI")
|
|
17
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
18
|
+
|
|
19
|
+
def common(sp):
|
|
20
|
+
sp.add_argument("--out", required=True); sp.add_argument("--theme", default=None)
|
|
21
|
+
|
|
22
|
+
pdf = sub.add_parser("pdf"); common(pdf); pdf.add_argument("--title", default="Documento")
|
|
23
|
+
pdf.add_argument("--subtitle", default=""); pdf.add_argument("--body", default="")
|
|
24
|
+
pdf.add_argument("--rows", default="", help="tabla: 'H1|H2' filas separadas por ',' celdas por '|'")
|
|
25
|
+
|
|
26
|
+
wd = sub.add_parser("word"); common(wd); wd.add_argument("--title", default="")
|
|
27
|
+
wd.add_argument("--blocks-json", default="[]")
|
|
28
|
+
|
|
29
|
+
ex = sub.add_parser("excel"); common(ex); ex.add_argument("--title", default="")
|
|
30
|
+
ex.add_argument("--sheets-json", default="[]")
|
|
31
|
+
|
|
32
|
+
a = p.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
from office_worker.core import render_pdf, create_word, create_excel
|
|
35
|
+
|
|
36
|
+
if a.cmd == "pdf":
|
|
37
|
+
rows=[]; headers=[]
|
|
38
|
+
if a.rows:
|
|
39
|
+
parts=a.rows.split(",")
|
|
40
|
+
headers=parts[0].split("|"); rows=[r.split("|") for r in parts[1:]]
|
|
41
|
+
data={"titulo":a.title,"subtitulo":a.subtitle,"resumen":a.body}
|
|
42
|
+
if rows: data.update({"headers":headers,"rows":rows})
|
|
43
|
+
tpl="<h1>{{ titulo }}</h1><p class='muted'>{{ subtitulo }}</p>{% if resumen %}<p>{{ resumen }}</p>{% endif %}{% if tabla is defined and tabla %}{{ tabla }}{% endif %}"
|
|
44
|
+
try: _print(render_pdf(tpl,a.out,data=data,theme=a.theme)); return 0
|
|
45
|
+
except Exception as e: print(json.dumps({"status":"error","error":str(e)})); return 1
|
|
46
|
+
elif a.cmd == "word":
|
|
47
|
+
try: blocks=json.loads(a.blocks_json or "[]"); _print(create_word(a.out,title=a.title,blocks=blocks,theme=a.theme)); return 0
|
|
48
|
+
except Exception as e: print(json.dumps({"status":"error","error":str(e)})); return 1
|
|
49
|
+
elif a.cmd == "excel":
|
|
50
|
+
try: sheets=json.loads(a.sheets_json or "[]"); _print(create_excel(a.out,title=a.title,sheets=sheets or None,theme=a.theme)); return 0
|
|
51
|
+
except Exception as e: print(json.dumps({"status":"error","error":str(e)})); return 1
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__": sys.exit(main())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .themes import load_theme, DEFAULT_THEME, css_vars
|
|
2
|
+
from .templates import render_pdf
|
|
3
|
+
from .word import create_word
|
|
4
|
+
from .excel import create_excel
|
|
5
|
+
from .slides import create_pptx
|
|
6
|
+
from .pdf import read_pdf, extract_tables, list_form_fields
|
|
7
|
+
__all__ = [
|
|
8
|
+
"load_theme","DEFAULT_THEME","css_vars",
|
|
9
|
+
"render_pdf","create_word","create_excel","create_pptx",
|
|
10
|
+
"read_pdf","extract_tables","list_form_fields",
|
|
11
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Generación de Excel (.xlsx) con openpyxl, aplicando tema corporativo."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
from openpyxl import Workbook
|
|
5
|
+
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
6
|
+
from openpyxl.utils import get_column_letter
|
|
7
|
+
|
|
8
|
+
def _hex(h): # "#003366" -> "FF003366" para openpyxl (ARGB)
|
|
9
|
+
return "FF" + h.lstrip("#").upper()
|
|
10
|
+
|
|
11
|
+
def create_excel(out_path, title="", sheets=None, theme=None):
|
|
12
|
+
"""Crea un .xlsx profesional.
|
|
13
|
+
|
|
14
|
+
sheets: lista de dicts {"name":"Hoja", "headers":[...], "rows":[[...], ...]}
|
|
15
|
+
Si None → una hoja única "Datos" vacía con título en A1.
|
|
16
|
+
Devuelve ruta absoluta. Aplica paleta del tema al header y filas alternas.
|
|
17
|
+
"""
|
|
18
|
+
from .themes import load_theme
|
|
19
|
+
th = load_theme(theme)
|
|
20
|
+
primary = _hex(th["primary"]); alt = _hex(th.get("row_alt","#F5F7FA"))
|
|
21
|
+
|
|
22
|
+
out_path = os.path.abspath(os.path.expanduser(out_path))
|
|
23
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
24
|
+
|
|
25
|
+
wb = Workbook(); wb.remove(wb.active)
|
|
26
|
+
thin = Side(style="thin", color="FFC8D4E0")
|
|
27
|
+
border = Border(left=thin,right=thin,top=thin,bottom=thin)
|
|
28
|
+
|
|
29
|
+
if not sheets:
|
|
30
|
+
ws = wb.create_sheet("Datos"); ws["A1"]=title or "The Office Worker"; ws["A1"].font=Font(bold=True,color=primary,size=12); wb.save(out_path); return out_path
|
|
31
|
+
|
|
32
|
+
for sh in sheets:
|
|
33
|
+
ws = wb.create_sheet(sh.get("name","Hoja")[:31])
|
|
34
|
+
headers = sh.get("headers",[]); rows = sh.get("rows",[])
|
|
35
|
+
start_row = 1
|
|
36
|
+
if title:
|
|
37
|
+
ws.cell(1,1,title).font = Font(bold=True,color=primary,size=12)
|
|
38
|
+
hrow = 2 if title else 1
|
|
39
|
+
for c,hv in enumerate(headers, start=1):
|
|
40
|
+
cell = ws.cell(hrow,c,str(hv)); cell.font=Font(bold=True,color="FFFFFFFF")
|
|
41
|
+
cell.fill=PatternFill("solid",fgColor=primary); cell.alignment=Alignment(horizontal="left"); cell.border=border
|
|
42
|
+
ridx = hrow+1; i=0
|
|
43
|
+
for row in rows:
|
|
44
|
+
for c,cv in enumerate(row,start=1):
|
|
45
|
+
cell=ws.cell(ridx,c,cv); cell.border=border; cell.alignment=Alignment(vertical="top")
|
|
46
|
+
if i%2==1: cell.fill=PatternFill("solid",fgColor=alt)
|
|
47
|
+
ridx+=1; i+=1
|
|
48
|
+
# auto ancho aprox por contenido (máx 40)
|
|
49
|
+
for c in range(1,len(headers)+1):
|
|
50
|
+
maxlen=max([len(str(headers[c-1]))] + [len(str(r[c-1])) for r in rows if c-1<len(r)] or [8])
|
|
51
|
+
ws.column_dimensions[get_column_letter(c)].width=min(max(maxlen+2,9),40)
|
|
52
|
+
|
|
53
|
+
wb.save(out_path)
|
|
54
|
+
if os.path.getsize(out_path) < 300: raise RuntimeError(f"XLSX sospechosamente pequeño ({os.path.getsize(out_path)}B)")
|
|
55
|
+
return out_path
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Lectura de PDF (input) — el gap que nadie cubre bien en los MCPs existentes.
|
|
2
|
+
|
|
3
|
+
Usa PyMuPDF (fitz) para texto/páginas y pdfplumber para tablas. pypdf para metadatos.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
def read_pdf(path, max_pages=None):
|
|
9
|
+
"""Extrae texto por página + metadatos. Devuelve dict {pages:[{n,text}], metadata, n_pages}."""
|
|
10
|
+
import fitz # PyMuPDF
|
|
11
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
12
|
+
if not os.path.exists(path): raise FileNotFoundError(path)
|
|
13
|
+
doc = fitz.open(path)
|
|
14
|
+
pages = []
|
|
15
|
+
limit = len(doc) if not max_pages else min(len(doc), int(max_pages))
|
|
16
|
+
for i in range(limit):
|
|
17
|
+
pg = doc[i]
|
|
18
|
+
pages.append({"n": i+1, "text": pg.get_text("text").strip()})
|
|
19
|
+
meta = doc.metadata or {}
|
|
20
|
+
out = {"path": path, "n_pages": len(doc), "metadata": {k:meta.get(k) for k in ("title","author","creator","producer") if meta.get(k)}, "pages": pages}
|
|
21
|
+
doc.close()
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
def extract_tables(path, max_pages=None):
|
|
25
|
+
"""Extrae tablas por página con pdfplumber. Devuelve {page: [tabla(...)]}."""
|
|
26
|
+
import pdfplumber
|
|
27
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
28
|
+
if not os.path.exists(path): raise FileNotFoundError(path)
|
|
29
|
+
result = {}
|
|
30
|
+
with pdfplumber.open(path) as pdf:
|
|
31
|
+
limit = len(pdf.pages) if not max_pages else min(len(pdf.pages), int(max_pages))
|
|
32
|
+
for i in range(limit):
|
|
33
|
+
tbls = pdf.pages[i].extract_tables() or []
|
|
34
|
+
if tbls: result[i+1] = tbls
|
|
35
|
+
return {"path": path, "tables_by_page": result, "n_tables": sum(len(v) for v in result.values())}
|
|
36
|
+
|
|
37
|
+
def list_form_fields(path):
|
|
38
|
+
"""Lista campos de formulario AcroForm (pypdf). Vacío si no es un form."""
|
|
39
|
+
from pypdf import PdfReader
|
|
40
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
41
|
+
if not os.path.exists(path): raise FileNotFoundError(path)
|
|
42
|
+
r = PdfReader(path)
|
|
43
|
+
fields = r.get_fields() or {}
|
|
44
|
+
return {"path": path, "is_form": bool(fields), "fields": {k:(v.get("/FT"), ) for k,v in fields.items()}}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Generación de PPTX editable vía html-to-pptx (Playwright Chromium).
|
|
2
|
+
|
|
3
|
+
Cada slide es un <section class="slide">. Inyecta el tema como CSS vars para
|
|
4
|
+
consistencia visual. Produce PPTX NATIVO editable (textos reales, no imágenes).
|
|
5
|
+
Requiere Playwright + Chromium ya instalados (verificado en Fase B).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os, tempfile
|
|
9
|
+
|
|
10
|
+
_SLIDE_CSS = """
|
|
11
|
+
* { box-sizing: border-box; margin: 0; }
|
|
12
|
+
body { font-family: var(--ow-font-body); color: var(--ow-text); }
|
|
13
|
+
.slide { width: 1280px; height: 720px; padding: 56px 64px; background: var(--ow-bg); display:flex; flex-direction:column; }
|
|
14
|
+
.slide h1 { font-family: var(--ow-font-title); font-size: 44px; color: var(--ow-primary); border-bottom: 3px solid var(--ow-primary); padding-bottom: 12px; margin-bottom: 24px; }
|
|
15
|
+
.slide h2 { font-family: var(--ow-font-title); font-size: 30px; color: var(--ow-primary); margin-bottom: 18px; }
|
|
16
|
+
.slide p, .slide li { font-size: 22px; line-height: 1.5; }
|
|
17
|
+
.slide ul { padding-left: 32px; margin-top: 10px; }
|
|
18
|
+
.slide table { width:100%; border-collapse:collapse; font-size:20px; margin-top:16px;}
|
|
19
|
+
.slide th{ background:var(--ow-primary); color:#fff; text-align:left; padding:10px 12px;}
|
|
20
|
+
.slide td{ padding:9px 12px; border-bottom:.5pt solid #c8d4e0;}
|
|
21
|
+
.slide tr:nth-child(even) td{ background:var(--ow-row-alt);}
|
|
22
|
+
.kicker{ color:var(--ow-accent); font-weight:bold; letter-spacing:.5px; text-transform:uppercase; font-size:18px; margin-bottom:8px;}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def create_pptx(out_path, slides=None, theme=None):
|
|
26
|
+
"""slides: lista de dicts {"title":..., "kicker":..., "bullets":[...]} o {"html":"..."} crudo.
|
|
27
|
+
|
|
28
|
+
Si slides es None → una portada genérica. Devuelve ruta absoluta del .pptx editable.
|
|
29
|
+
"""
|
|
30
|
+
from .themes import load_theme, css_vars
|
|
31
|
+
th = load_theme(theme)
|
|
32
|
+
|
|
33
|
+
parts = []
|
|
34
|
+
for s in (slides or [{"title": "The Office Worker", "bullets": ["Presentación generada"]}]):
|
|
35
|
+
if "html" in s and s["html"]:
|
|
36
|
+
parts.append(f'<section class="slide">{s["html"]}</section>')
|
|
37
|
+
continue
|
|
38
|
+
k = f'<div class="kicker">{s.get("kicker","")}</div>' if s.get("kicker") else ""
|
|
39
|
+
items = "".join(f"<li>{b}</li>" for b in s.get("bullets", []))
|
|
40
|
+
body = f"<ul>{items}</ul>" if items else f"<p>{s.get('subtitle','')}</p>" if s.get('subtitle') else ""
|
|
41
|
+
parts.append(
|
|
42
|
+
f'<section class="slide"><h1>{s.get("title","")}</h1>{k}{body}</section>'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
deck_html = (f'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
46
|
+
f'<style>{css_vars(th)}{_SLIDE_CSS}</style></head><body>{"".join(parts)}</body></html>')
|
|
47
|
+
|
|
48
|
+
out_path = os.path.abspath(os.path.expanduser(out_path))
|
|
49
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
50
|
+
|
|
51
|
+
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as tf:
|
|
52
|
+
tf.write(deck_html); tmp_html = tf.name
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
import asyncio, concurrent.futures
|
|
56
|
+
from html_to_pptx import convert
|
|
57
|
+
# El MCP server corre con su propio event loop; asyncio.run() fallaría ahí.
|
|
58
|
+
# Ejecutamos convert() en un hilo con su propio loop aislado.
|
|
59
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
|
|
60
|
+
result = ex.submit(asyncio.run, convert(tmp_html, out_path)).result(timeout=300)
|
|
61
|
+
finally:
|
|
62
|
+
try: os.unlink(tmp_html)
|
|
63
|
+
except OSError: pass
|
|
64
|
+
|
|
65
|
+
if not os.path.exists(result) or os.path.getsize(result) < 3000:
|
|
66
|
+
raise RuntimeError(f"PPTX generado inválido o vacío ({result})")
|
|
67
|
+
return result
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Núcleo de render: template Jinja + tema → documento.
|
|
2
|
+
|
|
3
|
+
H1 implementa el camino PDF (HTML/CSS → WeasyPrint), que es el motor de diseño ya
|
|
4
|
+
probado en producción (caso ISO 27001). docx/xlsx/pptx se agregan en hitos siguientes
|
|
5
|
+
reusando la misma entrada (template + data + theme).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader, BaseLoader, StrictUndefined
|
|
10
|
+
|
|
11
|
+
from .themes import load_theme, css_vars
|
|
12
|
+
|
|
13
|
+
# CSS base compartido por todas las plantillas (usa las variables del tema).
|
|
14
|
+
_BASE_CSS = """
|
|
15
|
+
@page { size: A4; margin: 1.6cm 1.5cm 1.8cm 1.5cm;
|
|
16
|
+
@bottom-center { content: "Página " counter(page) " de " counter(pages); font-size: 8pt; color: var(--ow-muted); } }
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
body { font-family: var(--ow-font-body); font-size: 10pt; line-height: 1.45; color: var(--ow-text); }
|
|
19
|
+
h1 { font-family: var(--ow-font-title); font-size: 17pt; color: var(--ow-primary); border-bottom: 2px solid var(--ow-primary); padding-bottom: 5pt; margin-top: 0; }
|
|
20
|
+
h2 { font-family: var(--ow-font-title); font-size: 12.5pt; color: var(--ow-primary); margin-top: 14pt; }
|
|
21
|
+
p, li { orphans: 3; widows: 3; }
|
|
22
|
+
table { width: 100%; border-collapse: collapse; font-size: 9pt; table-layout: fixed; margin-top: 8pt; }
|
|
23
|
+
th { background: var(--ow-primary); color: #fff; text-align: left; padding: 4pt 6pt; }
|
|
24
|
+
td { padding: 4pt 6pt; border-bottom: .5pt solid #c8d4e0; vertical-align: top; }
|
|
25
|
+
tr:nth-child(even) td { background: var(--ow-row-alt); }
|
|
26
|
+
.muted { color: var(--ow-muted); font-size: 9pt; }
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def _env(template_dir=None):
|
|
30
|
+
if template_dir and os.path.isdir(template_dir):
|
|
31
|
+
return Environment(loader=FileSystemLoader(template_dir), undefined=StrictUndefined, autoescape=False)
|
|
32
|
+
return Environment(loader=BaseLoader(), undefined=StrictUndefined, autoescape=False)
|
|
33
|
+
|
|
34
|
+
def render_pdf(template_html_or_path, out_path, data=None, theme=None, template_dir=None):
|
|
35
|
+
"""Rellena una plantilla HTML (Jinja) con `data` + `theme` y exporta a PDF vía WeasyPrint.
|
|
36
|
+
|
|
37
|
+
- template_html_or_path: string HTML (con placeholders {{ }}) o ruta a un .html.
|
|
38
|
+
- data: dict de variables para Jinja. Si hay lista 'rows' con 'headers', genera tabla.
|
|
39
|
+
- theme: dict o nombre/ruta (ver themes.load_theme). None → tema ADEN por defecto.
|
|
40
|
+
Devuelve la ruta absoluta del PDF generado. Crea out_path si no existe.
|
|
41
|
+
"""
|
|
42
|
+
from weasyprint import HTML
|
|
43
|
+
|
|
44
|
+
th = load_theme(theme)
|
|
45
|
+
|
|
46
|
+
# ¿es ruta a archivo o HTML inline?
|
|
47
|
+
if template_html_or_path and os.path.exists(str(template_html_or_path)):
|
|
48
|
+
tpl_text = open(template_html_or_path).read()
|
|
49
|
+
else:
|
|
50
|
+
tpl_text = str(template_html_or_path or "")
|
|
51
|
+
|
|
52
|
+
env = _env(template_dir)
|
|
53
|
+
tmpl = env.from_string(tpl_text)
|
|
54
|
+
|
|
55
|
+
# helper opcional para tablas declarativas en la plantilla via contexto simple
|
|
56
|
+
ctx = dict(data or {})
|
|
57
|
+
|
|
58
|
+
body = tmpl.render(**ctx)
|
|
59
|
+
|
|
60
|
+
html_doc = f"""<!DOCTYPE html><html lang="es"><head><meta charset="utf-8">
|
|
61
|
+
<style>{css_vars(th)}{_BASE_CSS}</style></head><body>{body}</body></html>"""
|
|
62
|
+
|
|
63
|
+
out_path = os.path.abspath(os.path.expanduser(out_path))
|
|
64
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
65
|
+
HTML(string=html_doc).write_pdf(out_path)
|
|
66
|
+
|
|
67
|
+
if os.path.getsize(out_path) < 500:
|
|
68
|
+
raise RuntimeError(f"PDF generado sospechosamente pequeño ({os.path.getsize(out_path)}B): revisar plantilla/datos")
|
|
69
|
+
return out_path
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Capa de temas corporativos.
|
|
2
|
+
|
|
3
|
+
Un tema es un dict con paleta + tipografía que se inyecta como CSS variables en las
|
|
4
|
+
plantillas HTML (para PDF vía WeasyPrint) y como constantes para docx/xlsx/pptx.
|
|
5
|
+
Esto resuelve el gap #3 de office-mcp: diseño consistente aplicable de una vez,
|
|
6
|
+
en vez de parámetros sueltos por llamada.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# Tema por defecto (paleta ADEN usada ya en producción).
|
|
12
|
+
DEFAULT_THEME = {
|
|
13
|
+
"name": "aden",
|
|
14
|
+
"primary": "#003366", # azul oscuro — encabezados, headers de tabla
|
|
15
|
+
"accent": "#3B82F6", # acento — cabeceras destacadas, enlaces
|
|
16
|
+
"text": "#1A202C", # cuerpo
|
|
17
|
+
"muted": "#718096", # secundario / pies
|
|
18
|
+
"row_alt": "#F5F7FA", # filas alternas de tabla
|
|
19
|
+
"bg": "#FFFFFF", # fondo
|
|
20
|
+
"font_title": "Helvetica Neue, Arial, sans-serif",
|
|
21
|
+
"font_body": "Helvetica Neue, Arial, sans-serif",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def load_theme(name_or_path: str | None) -> dict:
|
|
25
|
+
"""Devuelve un tema por nombre conocido o ruta a YAML/JSON. Sin arg → DEFAULT_THEME."""
|
|
26
|
+
if not name_or_path:
|
|
27
|
+
return dict(DEFAULT_THEME)
|
|
28
|
+
p = name_or_path
|
|
29
|
+
if os.path.exists(p):
|
|
30
|
+
text = open(p).read()
|
|
31
|
+
if p.endswith((".yaml", ".yml")):
|
|
32
|
+
import yaml # opcional; solo si cargan tema desde archivo YAML
|
|
33
|
+
data = yaml.safe_load(text) or {}
|
|
34
|
+
else:
|
|
35
|
+
import json as _json
|
|
36
|
+
data = _json.loads(text) or {}
|
|
37
|
+
base = dict(DEFAULT_THEME); base.update(data); base.setdefault("name", os.path.basename(p))
|
|
38
|
+
return base
|
|
39
|
+
# nombre conocido (extender después con más temas empaquetados)
|
|
40
|
+
return dict(DEFAULT_THEME)
|
|
41
|
+
|
|
42
|
+
def css_vars(theme: dict) -> str:
|
|
43
|
+
"""Bloque :root con las variables CSS del tema, listo para inyectar en <style>."""
|
|
44
|
+
return (
|
|
45
|
+
f":root{{ --ow-primary:{theme['primary']}; --ow-accent:{theme['accent']};"
|
|
46
|
+
f"--ow-text:{theme['text']}; --ow-muted:{theme['muted']};"
|
|
47
|
+
f"--ow-row-alt:{theme['row_alt']}; --ow-bg:{theme['bg']};"
|
|
48
|
+
f"--ow-font-title:{theme['font_title']}; --ow-font-body:{theme['font_body']}; }}"
|
|
49
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Generación de Word (.docx) con python-docx, aplicando tema corporativo."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
from docx import Document
|
|
5
|
+
from docx.shared import Pt, RGBColor, Inches
|
|
6
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
7
|
+
|
|
8
|
+
def _hex_to_rgb(h):
|
|
9
|
+
h = h.lstrip("#")
|
|
10
|
+
return RGBColor(int(h[0:2],16), int(h[2:4],16), int(h[4:6],16))
|
|
11
|
+
|
|
12
|
+
def create_word(out_path, title="", subtitle=None, blocks=None, theme=None):
|
|
13
|
+
"""Crea un .docx profesional.
|
|
14
|
+
|
|
15
|
+
blocks: lista de dicts. Tipos soportados:
|
|
16
|
+
{"type":"h1"|"h2"|"p", "text": "..."}
|
|
17
|
+
{"type":"table", "headers":[...], "rows":[[...], ...]}
|
|
18
|
+
Si blocks es None → documento mínimo con título. Devuelve ruta absoluta.
|
|
19
|
+
"""
|
|
20
|
+
from .themes import load_theme
|
|
21
|
+
th = load_theme(theme)
|
|
22
|
+
primary = _hex_to_rgb(th["primary"])
|
|
23
|
+
|
|
24
|
+
out_path = os.path.abspath(os.path.expanduser(out_path))
|
|
25
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
26
|
+
|
|
27
|
+
doc = Document()
|
|
28
|
+
# estilo base de fuente/cuerpo según tema
|
|
29
|
+
st = doc.styles["Normal"]; st.font.name = th.get("font_body","Arial"); st.font.size = Pt(10.5)
|
|
30
|
+
|
|
31
|
+
if title:
|
|
32
|
+
h = doc.add_heading(title, level=0);
|
|
33
|
+
for r in h.runs: r.font.color.rgb = primary
|
|
34
|
+
if subtitle:
|
|
35
|
+
p = doc.add_paragraph(subtitle)
|
|
36
|
+
if p.runs: p.runs[0].font.color.rgb = _hex_to_rgb(th["muted"])
|
|
37
|
+
|
|
38
|
+
for b in (blocks or []):
|
|
39
|
+
t = b.get("type","p")
|
|
40
|
+
if t == "h1":
|
|
41
|
+
hh = doc.add_heading(b["text"], level=1)
|
|
42
|
+
for r in hh.runs: r.font.color.rgb = primary
|
|
43
|
+
elif t == "h2":
|
|
44
|
+
hh = doc.add_heading(b["text"], level=2)
|
|
45
|
+
for r in hh.runs: r.font.color.rgb = primary
|
|
46
|
+
elif t == "table":
|
|
47
|
+
headers = b.get("headers",[]); rows = b.get("rows",[])
|
|
48
|
+
tbl = doc.add_table(rows=1, cols=len(headers)); tbl.style="Light Grid Accent 1"
|
|
49
|
+
hdr = tbl.rows[0].cells
|
|
50
|
+
for i,hv in enumerate(headers): hdr[i].text=str(hv)
|
|
51
|
+
for row in rows:
|
|
52
|
+
cells = tbl.add_row().cells
|
|
53
|
+
for i,cv in enumerate(row):
|
|
54
|
+
if i < len(cells): cells[i].text=str(cv)
|
|
55
|
+
else: # p / default
|
|
56
|
+
doc.add_paragraph(b.get("text",""))
|
|
57
|
+
|
|
58
|
+
doc.save(out_path)
|
|
59
|
+
if os.path.getsize(out_path) < 300: raise RuntimeError(f"DOCX sospechosamente pequeño ({os.path.getsize(out_path)}B)")
|
|
60
|
+
return out_path
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The Office Worker — MCP server (cara delgada sobre office_worker.core).
|
|
2
|
+
|
|
3
|
+
Meta: pocas tools poderosas (~8 aquí), no 47 CRUD genéricas → bajo overhead de contexto.
|
|
4
|
+
Cada tool crea/lee un archivo real y devuelve {status, path, ...} o {status:"error", error}.
|
|
5
|
+
Regla anti-loop en instructions: si una tool falla 2 veces, NO reintentar con variantes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os, json
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from office_worker.core import (
|
|
13
|
+
render_pdf as _render_pdf,
|
|
14
|
+
create_word as _create_word,
|
|
15
|
+
create_excel as _create_excel,
|
|
16
|
+
create_pptx as _create_pptx,
|
|
17
|
+
read_pdf as _read_pdf,
|
|
18
|
+
extract_tables as _extract_tables,
|
|
19
|
+
list_form_fields as _list_form_fields,
|
|
20
|
+
load_theme, DEFAULT_THEME,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
mcp = FastMCP(
|
|
24
|
+
"office-worker",
|
|
25
|
+
instructions=(
|
|
26
|
+
"The Office Worker: genera documentos de oficina profesionales (PDF/Word/Excel/PPTX) "
|
|
27
|
+
"desde datos + tema corporativo, y lee PDFs (texto/tablas/formularios). "
|
|
28
|
+
"Usa la tool que corresponda al formato pedido. Regla anti-loop: si una llamada falla 2 "
|
|
29
|
+
"veces, NO reintentes con variantes; reporta el error exacto y detente."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ok(path): return {"status": "ok", "path": os.path.abspath(path), "bytes": os.path.getsize(path)}
|
|
35
|
+
|
|
36
|
+
@mcp.tool()
|
|
37
|
+
def render_document(template_html: str, out_path: str, data_json: str = "{}", theme: str | None = None) -> dict:
|
|
38
|
+
"""Genera un PDF profesional desde plantilla HTML/Jinja + datos + tema."""
|
|
39
|
+
try: data = json.loads(data_json or "{}") or {}
|
|
40
|
+
except json.JSONDecodeError as e: return {"status":"error","error":f"data_json inválido: {e}"}
|
|
41
|
+
if isinstance(data.get("rows"), list):
|
|
42
|
+
headers=data.get("headers") or []
|
|
43
|
+
thead="".join(f"<th>{h}</th>" for h in headers)
|
|
44
|
+
body="\n".join("<tr>"+"".join(f"<td>{c}</td>" for c in r)+"</tr>" for r in data["rows"])
|
|
45
|
+
data["tabla"]=f"<table><thead><tr>{thead}</tr></thead><tbody>{body}</tbody></table>"
|
|
46
|
+
try: return _ok(_render_pdf(template_html, out_path, data=data, theme=theme))
|
|
47
|
+
except Exception as e: return {"status":"error","error":str(e)}
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
def create_word(out_path: str, title: str = "", subtitle: str | None = None, blocks_json: str = "[]", theme: str | None = None) -> dict:
|
|
51
|
+
"""Crea un .docx. blocks_json: JSON lista de {"type":"h1|h2|p|table","text"|"headers"/"rows"}."""
|
|
52
|
+
try: blocks=json.loads(blocks_json or "[]") or []
|
|
53
|
+
except json.JSONDecodeError as e: return {"status":"error","error":f"blocks_json inválido: {e}"}
|
|
54
|
+
try: return _ok(_create_word(out_path,title=title,subtitle=subtitle,blocks=blocks,theme=theme))
|
|
55
|
+
except Exception as e: return {"status":"error","error":str(e)}
|
|
56
|
+
|
|
57
|
+
@mcp.tool()
|
|
58
|
+
def create_excel(out_path: str, title: str = "", sheets_json: str = "[]", theme: str | None = None) -> dict:
|
|
59
|
+
"""Crea un .xlsx. sheets_json: JSON lista de {"name","headers":[...],"rows":[[...]]}."""
|
|
60
|
+
try: sheets=json.loads(sheets_json or "[]") or []
|
|
61
|
+
except json.JSONDecodeError as e: return {"status":"error","error":f"sheets_json inválido: {e}"}
|
|
62
|
+
try: return _ok(_create_excel(out_path,title=title,sheets=sheets or None,theme=theme))
|
|
63
|
+
except Exception as e: return {"status":"error","error":str(e)}
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
def create_pptx(out_path: str, slides_json: str = "[]", theme: str | None = None) -> dict:
|
|
67
|
+
"""Crea un .pptx EDITABLE. slides_json: JSON lista de {"title","kicker","bullets":[...]}."""
|
|
68
|
+
try: slides=json.loads(slides_json or "[]") or []
|
|
69
|
+
except json.JSONDecodeError as e: return {"status":"error","error":f"slides_json inválido: {e}"}
|
|
70
|
+
try: return _ok(_create_pptx(out_path,slides=slides or None,theme=theme))
|
|
71
|
+
except Exception as e: return {"status":"error","error":str(e)}
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
def read_pdf(path: str, max_pages: int | None = None) -> dict:
|
|
75
|
+
"""Lee texto + metadatos de un PDF (input). Devuelve páginas con su texto."""
|
|
76
|
+
try: return _read_pdf(path,max_pages=max_pages) | {"status":"ok"}
|
|
77
|
+
except Exception as e: return {"status":"error","error":str(e)}
|
|
78
|
+
|
|
79
|
+
@mcp.tool()
|
|
80
|
+
def pdf_extract_tables(path: str, max_pages: int | None = None) -> dict:
|
|
81
|
+
"""Extrae tablas estructuradas de un PDF (pdfplumber)."""
|
|
82
|
+
try: return _extract_tables(path,max_pages=max_pages) | {"status":"ok"}
|
|
83
|
+
except Exception as e: return {"status":"error","error":str(e)}
|
|
84
|
+
|
|
85
|
+
@mcp.tool()
|
|
86
|
+
def pdf_list_form_fields(path: str) -> dict:
|
|
87
|
+
"""Lista campos de formulario AcroForm de un PDF (vacío si no es form)."""
|
|
88
|
+
try: return _list_form_fields(path) | {"status":"ok"}
|
|
89
|
+
except Exception as e: return {"status":"error","error":str(e)}
|
|
90
|
+
|
|
91
|
+
@mcp.tool()
|
|
92
|
+
def list_themes(theme_name_or_path: str | None = None) -> dict:
|
|
93
|
+
"""Devuelve el tema resuelto (paleta+fuente) para usarlo como referencia de diseño."""
|
|
94
|
+
t = load_theme(theme_name_or_path) if theme_name_or_path else dict(DEFAULT_THEME)
|
|
95
|
+
return {"status":"ok","theme":t}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main(): mcp.run()
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__": main()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: office-worker-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Office Worker — your agent's document clerk: Word, Excel, PowerPoint & PDF with real design (templates + themes + PDF input).
|
|
5
|
+
Author: Julio Cardozo
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,office,pdf,docx,xlsx,pptx,documents,agent,templates
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Office/Business
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
License-File: NOTICE
|
|
15
|
+
Requires-Dist: mcp[cli]<2,>=1.27
|
|
16
|
+
Requires-Dist: python-docx>=1.0
|
|
17
|
+
Requires-Dist: openpyxl>=3.1
|
|
18
|
+
Requires-Dist: python-pptx>=1.0
|
|
19
|
+
Requires-Dist: weasyprint>=60
|
|
20
|
+
Requires-Dist: jinja2>=3.1
|
|
21
|
+
Requires-Dist: PyMuPDF>=1.24
|
|
22
|
+
Requires-Dist: pdfplumber>=0.11
|
|
23
|
+
Requires-Dist: pypdf>=4
|
|
24
|
+
Provides-Extra: pptx
|
|
25
|
+
Requires-Dist: html-to-pptx>=0.3; extra == "pptx"
|
|
26
|
+
Requires-Dist: playwright>=1.40; extra == "pptx"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
30
|
+
Requires-Dist: html-to-pptx>=0.3; extra == "dev"
|
|
31
|
+
Requires-Dist: playwright>=1.40; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# The Office Worker — `office-worker-mcp`
|
|
35
|
+
|
|
36
|
+
> **Your agent's document clerk.** Genera documentos de oficina profesionales —
|
|
37
|
+
> **PDF, Word, Excel y PowerPoint** — desde datos + un tema corporativo, y lee PDFs
|
|
38
|
+
> (texto, tablas y formularios). Diseñado para agentes de IA (Hermes, Claude, Cursor)
|
|
39
|
+
> vía [MCP](https://modelcontextprotocol.io), 100% local-first.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pip install office-worker-mcp # instala librería + MCP server + CLI 'owi'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Conectalo a tu agente (ejemplo config MCP / stdio):
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{ "mcpServers": { "office-worker": { "command": "office-worker-mcp" } } }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Y listo: le pedís *"haceme una factura/informe/deck"* en lenguaje natural y tu agente
|
|
52
|
+
usa las tools del oficinista para producir el archivo bien diseñado.
|
|
53
|
+
|
|
54
|
+
## Por qué existe (diferenciadores)
|
|
55
|
+
|
|
56
|
+
Los MCPs de documentos existentes son CRUD genéricos (47+ tools que inflan el contexto)
|
|
57
|
+
y no cubren diseño ni PDF como entrada. El Office Worker hace lo contrario:
|
|
58
|
+
|
|
59
|
+
| | office-mcp / takos | **The Office Worker** |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Tools | ~47–80 CRUD genéricas | **8 poderosas** (~2K tok/turno vs ~6.4K) |
|
|
62
|
+
| Plantillas con variables | ❌ | ✅ Jinja (`{{ var }}`) |
|
|
63
|
+
| Temas corporativos (paleta/fuente) | ❌ parámetros sueltos | ✅ aplicable de una vez a todos los formatos |
|
|
64
|
+
| PDF como **input** (leer/tablas/formularios) | ❌ | ✅ PyMuPDF + pdfplumber + pypdf |
|
|
65
|
+
| PPTX **editable** (textos nativos, no imágenes) | parcial | ✅ html-to-pptx verificado |
|
|
66
|
+
| Guía anti-loop para agentes | ❌ | ✅ SKILL.md con reglas probadas en producción |
|
|
67
|
+
|
|
68
|
+
## Las 8 tools
|
|
69
|
+
|
|
70
|
+
| Tool | Qué hace |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `render_document` | PDF profesional desde plantilla HTML/Jinja + datos + tema (WeasyPrint) |
|
|
73
|
+
| `create_word` | `.docx` con h1/h2/párrafos/tablas, tema aplicado (python-docx) |
|
|
74
|
+
| `create_excel` | `.xlsx` multi-hoja con paleta y filas alternas (openpyxl) |
|
|
75
|
+
| `create_pptx` | `.pptx` **editable** desde slides declarativas (html-to-pptx + Playwright) |
|
|
76
|
+
| `read_pdf` | Extrae texto por página + metadatos de un PDF (input) |
|
|
77
|
+
| `pdf_extract_tables` | Tablas estructuradas de un PDF (pdfplumber) |
|
|
78
|
+
| `pdf_list_form_fields` | Campos de formulario AcroForm de un PDF (pypdf) |
|
|
79
|
+
| `list_themes` | Devuelve la paleta/fuente del tema activo como referencia de diseño |
|
|
80
|
+
|
|
81
|
+
## Uso rápido (sin agente, vía CLI o Python)
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from office_worker.core import create_word, create_excel, render_pdf
|
|
85
|
+
|
|
86
|
+
create_word("acta.docx", title="Acta", subtitle="Reunión", blocks=[
|
|
87
|
+
{"type":"h2","text":"Puntos"}, {"type":"p","text":"Decisión tomada"}])
|
|
88
|
+
|
|
89
|
+
create_excel("balance.xlsx", title="Balance", sheets=[{"name":"Resumen",
|
|
90
|
+
"headers":["Rubro","Monto"],"rows":[["Ingresos",100],["Gastos",40]]}])
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Requisitos locales
|
|
94
|
+
|
|
95
|
+
- Python ≥ 3.9 · WeasyPrint (necesita fontconfig/pango en Linux) · LibreOffice opcional.
|
|
96
|
+
- **PPTX editable** (opcional): `pip install office-worker-mcp[pptx]` + `playwright install chromium`.
|
|
97
|
+
Sin este extra, las tools de PDF/Word/Excel funcionan igual; solo `create_pptx` queda deshabilitada.
|
|
98
|
+
- Todo corre en local; sin cloud.
|
|
99
|
+
|
|
100
|
+
## Desarrollo & tests
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install -e ".[dev]"
|
|
104
|
+
pytest # test_core.py (núcleo) + test_e2e_mcp.py (MCP end-to-end, 8/8 tools)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
El test E2E levanta el servidor MCP real, hace handshake y ejecuta cada tool creando/leyendo archivos válidos en disco.
|
|
108
|
+
|
|
109
|
+
## Licencia
|
|
110
|
+
|
|
111
|
+
MIT — ver [LICENSE](LICENSE). Agradecimientos en [NOTICE](NOTICE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
NOTICE
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
src/office_worker/__init__.py
|
|
6
|
+
src/office_worker/cli.py
|
|
7
|
+
src/office_worker/mcp_server.py
|
|
8
|
+
src/office_worker/core/__init__.py
|
|
9
|
+
src/office_worker/core/excel.py
|
|
10
|
+
src/office_worker/core/pdf.py
|
|
11
|
+
src/office_worker/core/slides.py
|
|
12
|
+
src/office_worker/core/templates.py
|
|
13
|
+
src/office_worker/core/themes.py
|
|
14
|
+
src/office_worker/core/word.py
|
|
15
|
+
src/office_worker_mcp.egg-info/PKG-INFO
|
|
16
|
+
src/office_worker_mcp.egg-info/SOURCES.txt
|
|
17
|
+
src/office_worker_mcp.egg-info/dependency_links.txt
|
|
18
|
+
src/office_worker_mcp.egg-info/entry_points.txt
|
|
19
|
+
src/office_worker_mcp.egg-info/requires.txt
|
|
20
|
+
src/office_worker_mcp.egg-info/top_level.txt
|
|
21
|
+
tests/test_core.py
|
|
22
|
+
tests/test_e2e_mcp.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
mcp[cli]<2,>=1.27
|
|
2
|
+
python-docx>=1.0
|
|
3
|
+
openpyxl>=3.1
|
|
4
|
+
python-pptx>=1.0
|
|
5
|
+
weasyprint>=60
|
|
6
|
+
jinja2>=3.1
|
|
7
|
+
PyMuPDF>=1.24
|
|
8
|
+
pdfplumber>=0.11
|
|
9
|
+
pypdf>=4
|
|
10
|
+
|
|
11
|
+
[dev]
|
|
12
|
+
pytest>=8
|
|
13
|
+
pytest-asyncio>=0.23
|
|
14
|
+
html-to-pptx>=0.3
|
|
15
|
+
playwright>=1.40
|
|
16
|
+
|
|
17
|
+
[pptx]
|
|
18
|
+
html-to-pptx>=0.3
|
|
19
|
+
playwright>=1.40
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
office_worker
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Test del núcleo: render_document → PDF real. (H1)"""
|
|
2
|
+
import os, sys, tempfile
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
4
|
+
|
|
5
|
+
from office_worker.core.templates import render_pdf
|
|
6
|
+
|
|
7
|
+
TPL = """<h1>{{ titulo }}</h1><p class="muted">{{ subtitulo }} · {{ fecha }}</p>
|
|
8
|
+
{% if tabla is defined and tabla %}{{ tabla }}{% endif %}"""
|
|
9
|
+
|
|
10
|
+
def test_render_pdf(tmp_path):
|
|
11
|
+
out = str(tmp_path / "out.pdf")
|
|
12
|
+
data = {
|
|
13
|
+
"titulo": "Informe de Prueba",
|
|
14
|
+
"subtitulo": "The Office Worker H1",
|
|
15
|
+
"fecha": "2026-09-05",
|
|
16
|
+
"headers": ["Rubro", "Valor"],
|
|
17
|
+
"rows": [["Ingresos", "$1.200.000"], ["Gastos", "$800.000"]],
|
|
18
|
+
}
|
|
19
|
+
# emular el helper de tabla que hace el MCP server antes de llamar al núcleo
|
|
20
|
+
headers = data["headers"]; rows = data["rows"]
|
|
21
|
+
thead = "".join(f"<th>{h}</th>" for h in headers)
|
|
22
|
+
body = "\n".join("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>" for r in rows)
|
|
23
|
+
data["tabla"] = f"<table><thead><tr>{thead}</tr></thead><tbody>{body}</tbody></table>"
|
|
24
|
+
|
|
25
|
+
path = render_pdf(TPL, out, data=data) # tema por defecto ADEN
|
|
26
|
+
assert os.path.exists(path), "PDF no creado"
|
|
27
|
+
size = os.path.getsize(path)
|
|
28
|
+
assert size > 500, f"PDF demasiado chico: {size}"
|
|
29
|
+
head = open(path, "rb").read(5)
|
|
30
|
+
assert head == b"%PDF-", f"No es PDF válido: {head!r}"
|
|
31
|
+
print(f"OK — PDF generado: {path} ({size} bytes)")
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
import pathlib, tempfile
|
|
35
|
+
with tempfile.TemporaryDirectory() as d:
|
|
36
|
+
test_render_pdf(pathlib.Path(d))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Test E2E del MCP completo como test de pytest (async).
|
|
2
|
+
|
|
3
|
+
Levanta el servidor MCP real, hace handshake y ejecuta cada tool creando/leyendo
|
|
4
|
+
archivos válidos en disco. PPTX se saltea si Playwright no está disponible.
|
|
5
|
+
Requiere: pytest-asyncio (en extras [dev]).
|
|
6
|
+
"""
|
|
7
|
+
import asyncio, json, os, sys, pathlib
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src"))
|
|
11
|
+
|
|
12
|
+
from mcp import ClientSession, StdioServerParameters
|
|
13
|
+
from mcp.client.stdio import stdio_client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_office_worker_mcp_e2e(tmp_path):
|
|
18
|
+
d = str(tmp_path)
|
|
19
|
+
env = {**os.environ, "PYTHONPATH": str(pathlib.Path(__file__).parent.parent / "src")}
|
|
20
|
+
params = StdioServerParameters(command=sys.executable, args=["-m", "office_worker.mcp_server"], env=env)
|
|
21
|
+
|
|
22
|
+
results = {}
|
|
23
|
+
async with stdio_client(params) as (r, w):
|
|
24
|
+
async with ClientSession(r, w) as s:
|
|
25
|
+
await s.initialize()
|
|
26
|
+
tools = [t.name for t in (await s.list_tools()).tools]
|
|
27
|
+
assert len(tools) == 8 and "render_document" in tools, f"tools inesperadas: {tools}"
|
|
28
|
+
|
|
29
|
+
async def call(name, args): return json.loads((await s.call_tool(name, args)).content[0].text)
|
|
30
|
+
|
|
31
|
+
# 1 PDF con tabla declarativa
|
|
32
|
+
r = await call("render_document", {
|
|
33
|
+
"template_html": "<h1>{{ titulo }}</h1><p class='muted'>{{ subtitulo }}</p>{% if tabla is defined and tabla %}{{ tabla }}{% endif %}",
|
|
34
|
+
"out_path": f"{d}/a.pdf",
|
|
35
|
+
"data_json": json.dumps({"titulo": "Informe", "subtitulo": "E2E", "headers": ["KPI", "V"], "rows": [["A", 1], ["B", 2]]})})
|
|
36
|
+
results["pdf"] = r; assert r["status"] == "ok" and os.path.exists(r["path"]), r
|
|
37
|
+
|
|
38
|
+
# 2 Word
|
|
39
|
+
r = await call("create_word", {
|
|
40
|
+
"out_path": f"{d}/b.docx", "title": "Acta", "subtitle": "Reunión",
|
|
41
|
+
"blocks_json": json.dumps([{"type": "h2", "text": "Puntos"}, {"type": "p", "text": "Decisión"}, {"type": "table", "headers": ["Item", "Estado"], "rows": [["X", "OK"]]}])})
|
|
42
|
+
results["docx"] = r; assert r["status"] == "ok" and os.path.exists(r["path"]), r
|
|
43
|
+
|
|
44
|
+
# 3 Excel multi-hoja
|
|
45
|
+
r = await call("create_excel", {
|
|
46
|
+
"out_path": f"{d}/c.xlsx", "title": "Balance",
|
|
47
|
+
"sheets_json": json.dumps([{"name": "Resumen", "headers": ["Rubro", "Monto"], "rows": [["Ingresos", 100], ["Gastos", 40]]}])})
|
|
48
|
+
results["xlsx"] = r; assert r["status"] == "ok" and os.path.exists(r["path"]), r
|
|
49
|
+
|
|
50
|
+
# 4 PPTX editable — se saltea si no hay Playwright instalado en el entorno
|
|
51
|
+
try:
|
|
52
|
+
import playwright # noqa: F401
|
|
53
|
+
have_pw = True
|
|
54
|
+
except Exception:
|
|
55
|
+
have_pw = False
|
|
56
|
+
if have_pw:
|
|
57
|
+
r = await call("create_pptx", {
|
|
58
|
+
"out_path": f"{d}/d.pptx",
|
|
59
|
+
"slides_json": json.dumps([{"title": "Portada", "kicker": "The Office Worker"}, {"title": "Agenda", "bullets": ["Intro", "Resultados"]}])})
|
|
60
|
+
results["pptx"] = r; assert r["status"] == "ok" and os.path.exists(r["path"]), r
|
|
61
|
+
else:
|
|
62
|
+
results["pptx"] = {"status": "skipped"}
|
|
63
|
+
|
|
64
|
+
# 5-7 PDF input sobre el PDF generado en (1)
|
|
65
|
+
r = await call("read_pdf", {"path": results["pdf"]["path"]}); results["read_pdf"] = r; assert r.get("n_pages", 0) >= 1 and r.get("pages"), r
|
|
66
|
+
r = await call("pdf_extract_tables", {"path": results["pdf"]["path"]}); results["tables"] = r; assert r.get("status") == "ok", r
|
|
67
|
+
r = await call("pdf_list_form_fields", {"path": results["pdf"]["path"]}); results["forms"] = r; assert r.get("status") == "ok", r
|
|
68
|
+
|
|
69
|
+
# 8 temas
|
|
70
|
+
r = await call("list_themes", {}); results["themes"] = r; assert r.get("theme", {}).get("primary"), r
|
|
71
|
+
|
|
72
|
+
# El PDF ya se validó arriba vía read_pdf (n_pages>=1 + texto real extraído).
|