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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """office_worker — The Office Worker.
2
+
3
+ Núcleo librería (sin depender de MCP). El wrapper MCP y la CLI son caras delgadas encima.
4
+ """
5
+ __version__ = "0.1.0"
@@ -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,6 @@
1
+ [console_scripts]
2
+ office-worker-mcp = office_worker.mcp_server:main
3
+ owi = office_worker.cli:main
4
+
5
+ [mcp.servers]
6
+ office_worker = office_worker.mcp_server:main
@@ -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,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).