tabella-docs 2.0.1__py3-none-any.whl
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.
- tabella/__init__.py +53 -0
- tabella/convert.py +187 -0
- tabella/create.py +777 -0
- tabella/read.py +248 -0
- tabella/utils.py +43 -0
- tabella_docs-2.0.1.dist-info/METADATA +315 -0
- tabella_docs-2.0.1.dist-info/RECORD +10 -0
- tabella_docs-2.0.1.dist-info/WHEEL +5 -0
- tabella_docs-2.0.1.dist-info/licenses/LICENSE +21 -0
- tabella_docs-2.0.1.dist-info/top_level.txt +1 -0
tabella/create.py
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
"""documents.create — Create .docx, .xlsx, .pptx, .pdf, .odt, .ods, .odp documents from structured JSON.
|
|
2
|
+
|
|
3
|
+
Merged from the doc_create plugin. Dependencies (python-docx, openpyxl,
|
|
4
|
+
python-pptx, weasyprint, odfpy, nano-pdf) are now installed directly in the
|
|
5
|
+
main Hermes venv — the old ~/.hermes/venvs/docs subprocess pattern is removed.
|
|
6
|
+
Builder functions import and use the libraries directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .utils import _get_output_dir, _is_in_output_dir, _resolve_output_dir
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _css_escape(s: str) -> str:
|
|
22
|
+
return s.replace('\\', '\\\\').replace("'", "\\'")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── builder functions (direct, no subprocess) ──
|
|
26
|
+
|
|
27
|
+
def _build_docx(content: dict, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
28
|
+
"""Build a .docx file from structured content."""
|
|
29
|
+
import os
|
|
30
|
+
from docx import Document
|
|
31
|
+
from docx.shared import Inches
|
|
32
|
+
|
|
33
|
+
options = options or {}
|
|
34
|
+
try:
|
|
35
|
+
if template and os.path.exists(template):
|
|
36
|
+
doc = Document(template)
|
|
37
|
+
else:
|
|
38
|
+
doc = Document()
|
|
39
|
+
|
|
40
|
+
if options.get("title"):
|
|
41
|
+
doc.add_heading(options["title"], level=0)
|
|
42
|
+
|
|
43
|
+
for section in content.get("sections", []):
|
|
44
|
+
stype = section.get("type", "paragraph")
|
|
45
|
+
if stype == "text":
|
|
46
|
+
stype = "paragraph" # "text" is a valid alias; unknown types fall through silently otherwise
|
|
47
|
+
if stype == "heading":
|
|
48
|
+
doc.add_heading(section.get("text", ""), level=section.get("level", 1))
|
|
49
|
+
elif stype == "paragraph":
|
|
50
|
+
p = doc.add_paragraph(section.get("text", ""))
|
|
51
|
+
if section.get("style"):
|
|
52
|
+
p.style = section["style"]
|
|
53
|
+
elif stype == "table":
|
|
54
|
+
rows = section.get("rows", [])
|
|
55
|
+
headers = section.get("headers", [])
|
|
56
|
+
total_rows = len(rows) + (1 if headers else 0)
|
|
57
|
+
num_cols = max(1, len(headers) or (len(rows[0]) if rows else 1))
|
|
58
|
+
table = doc.add_table(rows=max(1, total_rows), cols=num_cols)
|
|
59
|
+
table.style = "Table Grid"
|
|
60
|
+
row_idx = 0
|
|
61
|
+
if headers:
|
|
62
|
+
for col_idx, h in enumerate(headers):
|
|
63
|
+
table.rows[row_idx].cells[col_idx].text = str(h)
|
|
64
|
+
row_idx += 1
|
|
65
|
+
for row_data in rows:
|
|
66
|
+
for col_idx, cell in enumerate(row_data):
|
|
67
|
+
if col_idx < num_cols:
|
|
68
|
+
table.rows[row_idx].cells[col_idx].text = str(cell)
|
|
69
|
+
row_idx += 1
|
|
70
|
+
elif stype == "image":
|
|
71
|
+
doc.add_picture(section["path"], width=Inches(section.get("width", 5)))
|
|
72
|
+
elif stype == "page_break":
|
|
73
|
+
doc.add_page_break()
|
|
74
|
+
|
|
75
|
+
doc.save(output_path)
|
|
76
|
+
return {"success": True, "path": output_path, "format": "docx"}
|
|
77
|
+
except Exception as e:
|
|
78
|
+
return {"success": False, "error": str(e)}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_xlsx(content: dict, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
82
|
+
"""Build an .xlsx file from structured content."""
|
|
83
|
+
from openpyxl import Workbook
|
|
84
|
+
from openpyxl.styles import Font
|
|
85
|
+
from openpyxl.utils import get_column_letter
|
|
86
|
+
|
|
87
|
+
options = options or {}
|
|
88
|
+
try:
|
|
89
|
+
wb = Workbook()
|
|
90
|
+
sheets = content.get("sheets", [])
|
|
91
|
+
for idx, sheet in enumerate(sheets):
|
|
92
|
+
if idx == 0:
|
|
93
|
+
ws = wb.active
|
|
94
|
+
else:
|
|
95
|
+
ws = wb.create_sheet(title=sheet.get("name", "Sheet"))
|
|
96
|
+
if "name" in sheet and idx != 0:
|
|
97
|
+
ws.title = sheet["name"]
|
|
98
|
+
|
|
99
|
+
data = sheet.get("data", [])
|
|
100
|
+
formulas = sheet.get("formulas", {})
|
|
101
|
+
formats = sheet.get("formats", {})
|
|
102
|
+
|
|
103
|
+
for row_idx, row in enumerate(data, 1):
|
|
104
|
+
for col_idx, val in enumerate(row, 1):
|
|
105
|
+
cell_ref = f"{get_column_letter(col_idx)}{row_idx}"
|
|
106
|
+
cell = ws.cell(row=row_idx, column=col_idx, value=val)
|
|
107
|
+
if formats.get(cell_ref) == "header" or formats.get(cell_ref) == "bold":
|
|
108
|
+
cell.font = Font(bold=True)
|
|
109
|
+
if cell_ref in formulas:
|
|
110
|
+
cell.value = formulas[cell_ref]
|
|
111
|
+
|
|
112
|
+
if options.get("auto_size", True):
|
|
113
|
+
for col in ws.columns:
|
|
114
|
+
max_length = 0
|
|
115
|
+
column = col[0].column_letter
|
|
116
|
+
for cell in col:
|
|
117
|
+
try:
|
|
118
|
+
if len(str(cell.value)) > max_length:
|
|
119
|
+
max_length = len(str(cell.value))
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
adjusted_width = min(max_length + 2, 50)
|
|
123
|
+
ws.column_dimensions[column].width = adjusted_width
|
|
124
|
+
|
|
125
|
+
wb.save(output_path)
|
|
126
|
+
return {"success": True, "path": output_path, "format": "xlsx"}
|
|
127
|
+
except Exception as e:
|
|
128
|
+
return {"success": False, "error": str(e)}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_pptx(content: dict, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
132
|
+
"""Build a .pptx file from structured content."""
|
|
133
|
+
import os
|
|
134
|
+
from pptx import Presentation
|
|
135
|
+
from pptx.util import Inches
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
prs = Presentation(template) if template and os.path.exists(template) else Presentation()
|
|
139
|
+
|
|
140
|
+
for slide in content.get("slides", []):
|
|
141
|
+
layout_name = slide.get("layout", "title_and_content")
|
|
142
|
+
layout_idx = 0
|
|
143
|
+
for i, l in enumerate(prs.slide_layouts):
|
|
144
|
+
if l.name.lower().replace(" ", "_") == layout_name.lower().replace(" ", "_"):
|
|
145
|
+
layout_idx = i
|
|
146
|
+
break
|
|
147
|
+
s = prs.slides.add_slide(prs.slide_layouts[layout_idx])
|
|
148
|
+
|
|
149
|
+
if slide.get("title"):
|
|
150
|
+
s.shapes.title.text = slide["title"]
|
|
151
|
+
if slide.get("subtitle"):
|
|
152
|
+
for shape in s.placeholders:
|
|
153
|
+
if shape.placeholder_format.type == 2:
|
|
154
|
+
shape.text = slide["subtitle"]
|
|
155
|
+
break
|
|
156
|
+
if slide.get("bullets"):
|
|
157
|
+
body = s.shapes.placeholders[1]
|
|
158
|
+
tf = body.text_frame
|
|
159
|
+
tf.clear()
|
|
160
|
+
for i, bullet in enumerate(slide["bullets"]):
|
|
161
|
+
if i == 0:
|
|
162
|
+
p = tf.paragraphs[0]
|
|
163
|
+
else:
|
|
164
|
+
p = tf.add_paragraph()
|
|
165
|
+
p.text = bullet
|
|
166
|
+
p.level = 0
|
|
167
|
+
if slide.get("table"):
|
|
168
|
+
tbl_data = slide["table"]
|
|
169
|
+
rows = len(tbl_data.get("data", [])) + (1 if tbl_data.get("headers") else 0)
|
|
170
|
+
cols = max(len(tbl_data.get("headers", [])), len(tbl_data.get("data", [[]])[0]) if tbl_data.get("data") else 1)
|
|
171
|
+
left = Inches(1)
|
|
172
|
+
top = Inches(2)
|
|
173
|
+
width = Inches(8)
|
|
174
|
+
height = Inches(2)
|
|
175
|
+
table = s.shapes.add_table(rows, cols, left, top, width, height).table
|
|
176
|
+
if tbl_data.get("headers"):
|
|
177
|
+
for i, h in enumerate(tbl_data["headers"]):
|
|
178
|
+
table.cell(0, i).text = str(h)
|
|
179
|
+
for r_idx, row in enumerate(tbl_data.get("data", [])):
|
|
180
|
+
for c_idx, cell in enumerate(row):
|
|
181
|
+
table.cell(r_idx + (1 if tbl_data.get("headers") else 0), c_idx).text = str(cell)
|
|
182
|
+
|
|
183
|
+
prs.save(output_path)
|
|
184
|
+
return {"success": True, "path": output_path, "format": "pptx"}
|
|
185
|
+
except Exception as e:
|
|
186
|
+
return {"success": False, "error": str(e)}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _build_pdf(content: dict, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
190
|
+
"""Build a .pdf file from structured content via weasyprint."""
|
|
191
|
+
# Auto-wrap string content into structured format for convenience
|
|
192
|
+
if isinstance(content, str):
|
|
193
|
+
content = {
|
|
194
|
+
"pages": [
|
|
195
|
+
{
|
|
196
|
+
"elements": [
|
|
197
|
+
{"type": "heading", "text": content}
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
options = options or {}
|
|
203
|
+
try:
|
|
204
|
+
from weasyprint import HTML, CSS
|
|
205
|
+
from weasyprint.text.fonts import FontConfiguration
|
|
206
|
+
|
|
207
|
+
page_size = options.get("page_size", "letter")
|
|
208
|
+
title = options.get("title", "")
|
|
209
|
+
header_text = options.get("header", "")
|
|
210
|
+
footer_text = options.get("footer", "")
|
|
211
|
+
page_numbers = options.get("page_numbers", True)
|
|
212
|
+
|
|
213
|
+
css_size = "letter" if page_size.lower() == "letter" else "A4"
|
|
214
|
+
|
|
215
|
+
html_parts = ['<html><head><meta charset="utf-8"><title>' + html.escape(title or "Document") + '</title></head><body>']
|
|
216
|
+
|
|
217
|
+
for page in content.get("pages", []):
|
|
218
|
+
html_parts.append('<div class="page">')
|
|
219
|
+
for el in page.get("elements", []):
|
|
220
|
+
etype = el.get("type", "text")
|
|
221
|
+
if etype == "title":
|
|
222
|
+
html_parts.append(f"<h1>{html.escape(str(el.get('text', '')))}</h1>")
|
|
223
|
+
elif etype == "heading":
|
|
224
|
+
html_parts.append(f"<h2>{html.escape(str(el.get('text', '')))}</h2>")
|
|
225
|
+
elif etype == "text":
|
|
226
|
+
html_parts.append(f"<p>{html.escape(str(el.get('text', '')))}</p>")
|
|
227
|
+
elif etype == "table":
|
|
228
|
+
rows = el.get("rows", [])
|
|
229
|
+
html_parts.append('<table class="data-table">')
|
|
230
|
+
for row in rows:
|
|
231
|
+
html_parts.append("<tr>")
|
|
232
|
+
for cell in row:
|
|
233
|
+
html_parts.append(f"<td>{html.escape(str(cell))}</td>")
|
|
234
|
+
html_parts.append("</tr>")
|
|
235
|
+
html_parts.append("</table>")
|
|
236
|
+
elif etype == "spacer":
|
|
237
|
+
h = el.get("height", 24)
|
|
238
|
+
html_parts.append(f'<div style="height:{h}px"></div>')
|
|
239
|
+
html_parts.append('</div>')
|
|
240
|
+
|
|
241
|
+
html_parts.append('</body></html>')
|
|
242
|
+
html_str = "\n".join(html_parts)
|
|
243
|
+
|
|
244
|
+
css_str = f"""
|
|
245
|
+
@page {{ size: {css_size}; margin: 1in; }}
|
|
246
|
+
body {{ font-family: Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.4; }}
|
|
247
|
+
h1 {{ font-size: 18pt; margin-bottom: 12pt; }}
|
|
248
|
+
h2 {{ font-size: 14pt; margin-top: 14pt; margin-bottom: 8pt; }}
|
|
249
|
+
table {{ width: 100%; border-collapse: collapse; margin: 10pt 0; }}
|
|
250
|
+
td, th {{ border: 1px solid #ccc; padding: 6px; text-align: left; }}
|
|
251
|
+
.page {{ page-break-after: always; }}
|
|
252
|
+
.page:last-child {{ page-break-after: auto; }}
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
if header_text:
|
|
256
|
+
css_str += f"""
|
|
257
|
+
@page {{ @top-center {{ content: '{_css_escape(header_text)}'; font-size: 9pt; color: #666; }} }}
|
|
258
|
+
"""
|
|
259
|
+
if footer_text or page_numbers:
|
|
260
|
+
footer_content = footer_text or ""
|
|
261
|
+
if page_numbers:
|
|
262
|
+
footer_content += " Page " + ("" if not footer_text else " ") + "counter(page)"
|
|
263
|
+
css_str += f"""
|
|
264
|
+
@page {{ @bottom-center {{ content: '{_css_escape(footer_content)}'; font-size: 9pt; color: #666; }} }}
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
font_config = FontConfiguration()
|
|
268
|
+
HTML(string=html_str).write_pdf(output_path, stylesheets=[CSS(string=css_str, font_config=font_config)])
|
|
269
|
+
return {"success": True, "path": output_path, "format": "pdf"}
|
|
270
|
+
except Exception as e:
|
|
271
|
+
return {"success": False, "error": str(e)}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _build_odt(content: dict, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
275
|
+
"""Build an .odt file from structured content."""
|
|
276
|
+
options = options or {}
|
|
277
|
+
try:
|
|
278
|
+
from odf.opendocument import OpenDocumentText
|
|
279
|
+
from odf.text import P, H
|
|
280
|
+
from odf.table import Table, TableRow, TableCell
|
|
281
|
+
|
|
282
|
+
doc = OpenDocumentText()
|
|
283
|
+
if options.get("title"):
|
|
284
|
+
h = H(outlinelevel=1, text=options["title"])
|
|
285
|
+
doc.text.addElement(h)
|
|
286
|
+
|
|
287
|
+
for section in content.get("sections", []):
|
|
288
|
+
stype = section.get("type", "paragraph")
|
|
289
|
+
if stype == "text":
|
|
290
|
+
stype = "paragraph" # "text" alias, same normalization as _build_docx
|
|
291
|
+
if stype == "heading":
|
|
292
|
+
h = H(outlinelevel=section.get("level", 1), text=section.get("text", ""))
|
|
293
|
+
doc.text.addElement(h)
|
|
294
|
+
elif stype == "paragraph":
|
|
295
|
+
p = P(text=section.get("text", ""))
|
|
296
|
+
doc.text.addElement(p)
|
|
297
|
+
elif stype == "table":
|
|
298
|
+
rows = section.get("rows", [])
|
|
299
|
+
headers = section.get("headers", [])
|
|
300
|
+
total_rows = len(rows) + (1 if headers else 0)
|
|
301
|
+
num_cols = max(1, len(headers) or (len(rows[0]) if rows else 1))
|
|
302
|
+
table = Table()
|
|
303
|
+
for r in range(total_rows):
|
|
304
|
+
tr = TableRow()
|
|
305
|
+
for c in range(num_cols):
|
|
306
|
+
tc = TableCell()
|
|
307
|
+
p = P(text="")
|
|
308
|
+
tc.addElement(p)
|
|
309
|
+
tr.addElement(tc)
|
|
310
|
+
table.addElement(tr)
|
|
311
|
+
doc.text.addElement(table)
|
|
312
|
+
|
|
313
|
+
doc.save(output_path)
|
|
314
|
+
return {"success": True, "path": output_path, "format": "odt"}
|
|
315
|
+
except Exception as e:
|
|
316
|
+
return {"success": False, "error": str(e)}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _build_ods(content: dict, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
320
|
+
"""Build an .ods file from structured content."""
|
|
321
|
+
try:
|
|
322
|
+
from odf.opendocument import OpenDocumentSpreadsheet
|
|
323
|
+
from odf.table import Table, TableRow, TableCell
|
|
324
|
+
from odf.text import P
|
|
325
|
+
|
|
326
|
+
doc = OpenDocumentSpreadsheet()
|
|
327
|
+
for sheet in content.get("sheets", []):
|
|
328
|
+
table = Table(name=sheet.get("name", "Sheet1"))
|
|
329
|
+
data = sheet.get("data", [])
|
|
330
|
+
for row in data:
|
|
331
|
+
tr = TableRow()
|
|
332
|
+
for cell in row:
|
|
333
|
+
tc = TableCell()
|
|
334
|
+
p = P(text=str(cell))
|
|
335
|
+
tc.addElement(p)
|
|
336
|
+
tr.addElement(tc)
|
|
337
|
+
table.addElement(tr)
|
|
338
|
+
doc.spreadsheet.addElement(table)
|
|
339
|
+
doc.save(output_path)
|
|
340
|
+
return {"success": True, "path": output_path, "format": "ods"}
|
|
341
|
+
except Exception as e:
|
|
342
|
+
return {"success": False, "error": str(e)}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _build_odp(content: dict, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
346
|
+
"""Build an .odp file from structured content."""
|
|
347
|
+
try:
|
|
348
|
+
from odf.opendocument import OpenDocumentPresentation
|
|
349
|
+
from odf.draw import Page, Frame, TextBox
|
|
350
|
+
from odf.text import P
|
|
351
|
+
|
|
352
|
+
doc = OpenDocumentPresentation()
|
|
353
|
+
for slide in content.get("slides", []):
|
|
354
|
+
page = Page(masterpagename="Default")
|
|
355
|
+
doc.presentation.addElement(page)
|
|
356
|
+
if slide.get("title"):
|
|
357
|
+
frame = Frame(x="1cm", y="1cm", width="18cm", height="2cm")
|
|
358
|
+
textbox = TextBox()
|
|
359
|
+
p = P(text=slide["title"])
|
|
360
|
+
textbox.addElement(p)
|
|
361
|
+
frame.addElement(textbox)
|
|
362
|
+
page.addElement(frame)
|
|
363
|
+
if slide.get("subtitle"):
|
|
364
|
+
frame = Frame(x="1cm", y="3.5cm", width="18cm", height="1cm")
|
|
365
|
+
textbox = TextBox()
|
|
366
|
+
p = P(text=slide["subtitle"])
|
|
367
|
+
textbox.addElement(p)
|
|
368
|
+
frame.addElement(textbox)
|
|
369
|
+
page.addElement(frame)
|
|
370
|
+
if slide.get("bullets"):
|
|
371
|
+
frame = Frame(x="1cm", y="5cm", width="18cm", height="10cm")
|
|
372
|
+
textbox = TextBox()
|
|
373
|
+
for bullet in slide["bullets"]:
|
|
374
|
+
p = P(text="• " + bullet)
|
|
375
|
+
textbox.addElement(p)
|
|
376
|
+
frame.addElement(textbox)
|
|
377
|
+
page.addElement(frame)
|
|
378
|
+
doc.save(output_path)
|
|
379
|
+
return {"success": True, "path": output_path, "format": "odp"}
|
|
380
|
+
except Exception as e:
|
|
381
|
+
return {"success": False, "error": str(e)}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ── content normalization ──
|
|
385
|
+
|
|
386
|
+
def _is_html(text: str) -> bool:
|
|
387
|
+
"""Heuristic: does the string look like HTML?"""
|
|
388
|
+
if not isinstance(text, str):
|
|
389
|
+
return False
|
|
390
|
+
stripped = text.strip()
|
|
391
|
+
return stripped.startswith("<") and (">" in stripped or stripped.lower().startswith("<!doctype"))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _normalize_content(format: str, content, options: dict = None) -> dict:
|
|
395
|
+
"""Convert string content into the structured dict expected by each builder.
|
|
396
|
+
|
|
397
|
+
If content is already a dict, return it as-is.
|
|
398
|
+
If content is a plain string:
|
|
399
|
+
- For PDF: if it looks like HTML, wrap as {'html': '<raw html>'};
|
|
400
|
+
otherwise wrap as a single text element page.
|
|
401
|
+
- For DOCX/ODT: wrap as a single paragraph section.
|
|
402
|
+
- For XLSX/ODS: wrap as a single-cell single-sheet.
|
|
403
|
+
- For PPTX/ODP: wrap as a single title slide.
|
|
404
|
+
"""
|
|
405
|
+
if isinstance(content, dict):
|
|
406
|
+
return content
|
|
407
|
+
|
|
408
|
+
if not isinstance(content, str):
|
|
409
|
+
content = str(content)
|
|
410
|
+
|
|
411
|
+
fmt = format.lower()
|
|
412
|
+
|
|
413
|
+
if fmt == "pdf":
|
|
414
|
+
if _is_html(content):
|
|
415
|
+
return {"html": content}
|
|
416
|
+
return {
|
|
417
|
+
"pages": [
|
|
418
|
+
{
|
|
419
|
+
"elements": [
|
|
420
|
+
{"type": "text", "text": content}
|
|
421
|
+
]
|
|
422
|
+
}
|
|
423
|
+
]
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if fmt in ("docx", "odt"):
|
|
427
|
+
return {
|
|
428
|
+
"sections": [
|
|
429
|
+
{"type": "paragraph", "text": content}
|
|
430
|
+
]
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if fmt in ("xlsx", "ods"):
|
|
434
|
+
return {
|
|
435
|
+
"sheets": [
|
|
436
|
+
{
|
|
437
|
+
"name": "Sheet1",
|
|
438
|
+
"data": [[content]]
|
|
439
|
+
}
|
|
440
|
+
]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if fmt in ("pptx", "odp"):
|
|
444
|
+
return {
|
|
445
|
+
"slides": [
|
|
446
|
+
{"title": content, "layout": "title_slide"}
|
|
447
|
+
]
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return content
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ── raw HTML PDF builder ──
|
|
454
|
+
|
|
455
|
+
def _build_pdf_from_html(content: dict, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
456
|
+
"""Build a PDF directly from raw HTML string."""
|
|
457
|
+
html_str = content.get("html", "")
|
|
458
|
+
options = options or {}
|
|
459
|
+
try:
|
|
460
|
+
from weasyprint import HTML, CSS
|
|
461
|
+
from weasyprint.text.fonts import FontConfiguration
|
|
462
|
+
|
|
463
|
+
page_size = options.get("page_size", "letter")
|
|
464
|
+
css_size = "letter" if page_size.lower() == "letter" else "A4"
|
|
465
|
+
title = options.get("title", "")
|
|
466
|
+
header_text = options.get("header", "")
|
|
467
|
+
footer_text = options.get("footer", "")
|
|
468
|
+
page_numbers = options.get("page_numbers", True)
|
|
469
|
+
|
|
470
|
+
if "<!doctype" not in html_str.lower() and "<html" not in html_str.lower():
|
|
471
|
+
_t = title or "Document"
|
|
472
|
+
html_str = (
|
|
473
|
+
"\n<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><title>" + _t +
|
|
474
|
+
"</title></head><body>\n" + html_str + "</body></html>"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
css_lines = []
|
|
478
|
+
css_lines.append("@page { size: " + css_size + "; margin: 1in; }")
|
|
479
|
+
css_lines.append("body { font-family: Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.4; }")
|
|
480
|
+
css_lines.append("h1 { font-size: 18pt; margin-bottom: 12pt; }")
|
|
481
|
+
css_lines.append("h2 { font-size: 14pt; margin-top: 14pt; margin-bottom: 8pt; }")
|
|
482
|
+
css_lines.append("table { width: 100%; border-collapse: collapse; margin: 10pt 0; }")
|
|
483
|
+
css_lines.append("td, th { border: 1px solid #ccc; padding: 6px; text-align: left; }")
|
|
484
|
+
|
|
485
|
+
if header_text:
|
|
486
|
+
css_lines.append("@page { @top-center { content: '" + header_text.replace("'", "\\'") + "'; font-size: 9pt; color: #666; } }")
|
|
487
|
+
if footer_text or page_numbers:
|
|
488
|
+
footer_content = footer_text or ""
|
|
489
|
+
if page_numbers:
|
|
490
|
+
footer_content += " Page " + ("" if not footer_text else " ") + "counter(page)"
|
|
491
|
+
css_lines.append("@page { @bottom-center { content: '" + footer_content.replace("'", "\\'") + "'; font-size: 9pt; color: #666; } }")
|
|
492
|
+
|
|
493
|
+
css_str = "\n".join(css_lines)
|
|
494
|
+
font_config = FontConfiguration()
|
|
495
|
+
HTML(string=html_str).write_pdf(output_path, stylesheets=[CSS(string=css_str, font_config=font_config)])
|
|
496
|
+
return {"success": True, "path": output_path, "format": "pdf"}
|
|
497
|
+
except Exception as e:
|
|
498
|
+
return {"success": False, "error": str(e)}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ── core create_document ──
|
|
502
|
+
|
|
503
|
+
def create_document(format: str, content, output_path: str, template: str = None, options: dict = None) -> dict:
|
|
504
|
+
"""Create a document in the specified format.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
format: "docx", "xlsx", "pptx", "pdf", "odt", "ods", or "odp"
|
|
508
|
+
content: Structured content dict, or a plain string (auto-normalized)
|
|
509
|
+
output_path: Absolute path for the output file
|
|
510
|
+
template: Optional template path (docx/pptx only — loads styles/layouts from the file; ignored by other formats)
|
|
511
|
+
options: Optional format-specific options
|
|
512
|
+
"""
|
|
513
|
+
format = format.lower()
|
|
514
|
+
out = Path(output_path).expanduser().resolve()
|
|
515
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
|
|
517
|
+
if not _is_in_output_dir(out):
|
|
518
|
+
logger.warning(
|
|
519
|
+
"doc_create: output file %s is outside the configured output directory. "
|
|
520
|
+
"Files saved here may not be synced.",
|
|
521
|
+
out,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
builders = {
|
|
525
|
+
"docx": _build_docx,
|
|
526
|
+
"xlsx": _build_xlsx,
|
|
527
|
+
"pptx": _build_pptx,
|
|
528
|
+
"pdf": _build_pdf,
|
|
529
|
+
"odt": _build_odt,
|
|
530
|
+
"ods": _build_ods,
|
|
531
|
+
"odp": _build_odp,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if format not in builders:
|
|
535
|
+
return {"success": False, "error": f"Unsupported format: {format}"}
|
|
536
|
+
|
|
537
|
+
# Normalize string content into structured dict
|
|
538
|
+
normalized = _normalize_content(format, content, options=options)
|
|
539
|
+
|
|
540
|
+
# PDF: if normalized content has raw HTML key, use the HTML-specific builder
|
|
541
|
+
if format == "pdf" and "html" in normalized:
|
|
542
|
+
result = _build_pdf_from_html(normalized, str(out), template=template, options=options or {})
|
|
543
|
+
else:
|
|
544
|
+
result = builders[format](normalized, str(out), template=template, options=options or {})
|
|
545
|
+
|
|
546
|
+
if result.get("success") and "path" in result:
|
|
547
|
+
result["message"] = f"MEDIA:{result['path']}\n\nDocument created ({format}): {result['path']}"
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ── PDF editing ──
|
|
552
|
+
|
|
553
|
+
def _edit_pdf_text(path: str, page: int, instruction: str, output_path: str = None) -> dict:
|
|
554
|
+
"""Edit PDF text via pymupdf search-and-replace (no LLM, provider-agnostic)."""
|
|
555
|
+
try:
|
|
556
|
+
import fitz # pymupdf
|
|
557
|
+
except ImportError:
|
|
558
|
+
return {"success": False, "error": "pymupdf not installed."}
|
|
559
|
+
|
|
560
|
+
doc = fitz.open(path)
|
|
561
|
+
if page < 1 or page > len(doc):
|
|
562
|
+
return {"success": False, "error": f"Page {page} out of range (1-{len(doc)})."}
|
|
563
|
+
|
|
564
|
+
page_obj = doc[page - 1]
|
|
565
|
+
original_text = page_obj.get_text()
|
|
566
|
+
|
|
567
|
+
# Try to parse "change X to Y" / "replace X with Y" / "fix X to Y" patterns
|
|
568
|
+
patterns = [
|
|
569
|
+
r"(?:change|replace|fix|update|correct)\s+['\"]?(.+?)['\"]?\s+(?:to|with|->)\s+['\"]?(.+?)['\"]?$",
|
|
570
|
+
r"['\"]?(.+?)['\"]?\s*->\s*['\"]?(.+?)['\"]?$",
|
|
571
|
+
]
|
|
572
|
+
|
|
573
|
+
search_text = None
|
|
574
|
+
replace_text = None
|
|
575
|
+
for pattern in patterns:
|
|
576
|
+
m = re.search(pattern, instruction.strip(), re.IGNORECASE)
|
|
577
|
+
if m:
|
|
578
|
+
search_text = m.group(1).strip().strip("'\"")
|
|
579
|
+
replace_text = m.group(2).strip().strip("'\"")
|
|
580
|
+
break
|
|
581
|
+
|
|
582
|
+
if not search_text:
|
|
583
|
+
return {
|
|
584
|
+
"success": False,
|
|
585
|
+
"error": (
|
|
586
|
+
"Could not parse instruction. Use format: "
|
|
587
|
+
"'change <search> to <replace>' or 'replace <search> with <replace>'"
|
|
588
|
+
),
|
|
589
|
+
"page_text": original_text[:2000],
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# pymupdf text replacement: find rects, redact, insert new text
|
|
593
|
+
found = False
|
|
594
|
+
text_instances = page_obj.search_for(search_text)
|
|
595
|
+
|
|
596
|
+
if not text_instances:
|
|
597
|
+
# Try case-insensitive fallback by scanning all text blocks
|
|
598
|
+
blocks = page_obj.get_text("blocks")
|
|
599
|
+
for block in blocks:
|
|
600
|
+
if search_text.lower() in block[4].lower():
|
|
601
|
+
# Found in a block, try to locate it more precisely
|
|
602
|
+
text_instances = page_obj.search_for(block[4].strip()[:len(search_text) + 20])
|
|
603
|
+
break
|
|
604
|
+
|
|
605
|
+
if not text_instances:
|
|
606
|
+
return {
|
|
607
|
+
"success": False,
|
|
608
|
+
"error": f"Text '{search_text}' not found on page {page}.",
|
|
609
|
+
"page_text": original_text[:2000],
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
for rect in text_instances:
|
|
613
|
+
# Redact the area and insert replacement text
|
|
614
|
+
page_obj.add_redact_annot(rect, text=replace_text, fontsize=11)
|
|
615
|
+
found = True
|
|
616
|
+
|
|
617
|
+
if found:
|
|
618
|
+
page_obj.apply_redactions()
|
|
619
|
+
|
|
620
|
+
if not output_path:
|
|
621
|
+
output_path = str(Path(path).parent / f"{Path(path).stem}_edited.pdf")
|
|
622
|
+
|
|
623
|
+
doc.save(output_path)
|
|
624
|
+
doc.close()
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
"success": True,
|
|
628
|
+
"path": output_path,
|
|
629
|
+
"format": "pdf",
|
|
630
|
+
"page": page,
|
|
631
|
+
"instruction": instruction,
|
|
632
|
+
"search": search_text,
|
|
633
|
+
"replace": replace_text,
|
|
634
|
+
"message": f"PDF edited (page {page}). Output: {output_path}",
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# Kept for backward compat — calls the text-based editor
|
|
639
|
+
_edit_pdf_via_nano_pdf = _edit_pdf_text
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ── Hermes handlers ──
|
|
643
|
+
|
|
644
|
+
def _handle_doc_create(params: dict, **kwargs) -> str:
|
|
645
|
+
"""Handler for doc_create_document tool."""
|
|
646
|
+
# Accept either "output_path" or "path" for backward compatibility
|
|
647
|
+
output_path = params.get("output_path") or params.get("path")
|
|
648
|
+
|
|
649
|
+
# Read user_files from middleware-injected _user_context (if present).
|
|
650
|
+
# This replaces the old HERMES_PROFILE / ctx / cwd fallback chain.
|
|
651
|
+
user_context = params.get("_user_context", {})
|
|
652
|
+
user_files = user_context.get("user_files")
|
|
653
|
+
|
|
654
|
+
if not output_path:
|
|
655
|
+
# No explicit path — use user_files/Documents if available, else fallback
|
|
656
|
+
ts = int(time.time())
|
|
657
|
+
fmt = params["format"]
|
|
658
|
+
if user_files:
|
|
659
|
+
docs_dir = Path(user_files) / "Documents"
|
|
660
|
+
docs_dir.mkdir(parents=True, exist_ok=True)
|
|
661
|
+
output_path = str(docs_dir / f"doc_{ts}.{fmt}")
|
|
662
|
+
else:
|
|
663
|
+
# Fallback: profile cache (preserves existing behaviour for solo users)
|
|
664
|
+
cache_dir = _get_output_dir()
|
|
665
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
666
|
+
output_path = str(cache_dir / f"doc_{ts}.{fmt}")
|
|
667
|
+
elif user_files and "/tmp/" in output_path:
|
|
668
|
+
# Redirect /tmp paths to user_files/Documents for media delivery compatibility
|
|
669
|
+
ts = int(time.time())
|
|
670
|
+
fmt = params["format"]
|
|
671
|
+
p = Path(output_path)
|
|
672
|
+
if p.name and p.name != ".":
|
|
673
|
+
filename = p.stem + f"_{ts}.{fmt}"
|
|
674
|
+
else:
|
|
675
|
+
filename = f"doc_{ts}.{fmt}"
|
|
676
|
+
docs_dir = Path(user_files) / "Documents"
|
|
677
|
+
docs_dir.mkdir(parents=True, exist_ok=True)
|
|
678
|
+
output_path = str(docs_dir / filename)
|
|
679
|
+
logger.info("doc_create: redirected /tmp path to %s via user_files", output_path)
|
|
680
|
+
|
|
681
|
+
result = create_document(
|
|
682
|
+
format=params["format"],
|
|
683
|
+
content=params["content"],
|
|
684
|
+
output_path=output_path,
|
|
685
|
+
template=params.get("template"),
|
|
686
|
+
options=params.get("options"),
|
|
687
|
+
)
|
|
688
|
+
if isinstance(result, dict):
|
|
689
|
+
return json.dumps(result)
|
|
690
|
+
return result
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _handle_doc_edit(params: dict, **kwargs) -> str:
|
|
694
|
+
"""Handler for doc_edit_pdf tool."""
|
|
695
|
+
result = _edit_pdf_via_nano_pdf(
|
|
696
|
+
path=params["path"],
|
|
697
|
+
page=params.get("page", 1),
|
|
698
|
+
instruction=params["instruction"],
|
|
699
|
+
output_path=params.get("output_path"),
|
|
700
|
+
)
|
|
701
|
+
if isinstance(result, dict):
|
|
702
|
+
return json.dumps(result)
|
|
703
|
+
return result
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ── plugin registration ──
|
|
707
|
+
def register(registry):
|
|
708
|
+
registry.register_tool(
|
|
709
|
+
name="doc_create_document",
|
|
710
|
+
toolset="doc-create",
|
|
711
|
+
schema={
|
|
712
|
+
"type": "object",
|
|
713
|
+
"properties": {
|
|
714
|
+
"format": {
|
|
715
|
+
"type": "string",
|
|
716
|
+
"enum": ["docx", "xlsx", "pptx", "pdf", "odt", "ods", "odp"],
|
|
717
|
+
"description": "Output document format.",
|
|
718
|
+
},
|
|
719
|
+
"content": {
|
|
720
|
+
"anyOf": [
|
|
721
|
+
{"type": "string", "description": "Plain text or HTML content (auto-detected and normalized)."},
|
|
722
|
+
{"type": "object", "description": "Structured content dict. Format-specific schema (see references/)."},
|
|
723
|
+
],
|
|
724
|
+
"description": "Document content. Accepts plain text, HTML, or structured dict.",
|
|
725
|
+
},
|
|
726
|
+
"output_path": {
|
|
727
|
+
"type": "string",
|
|
728
|
+
"description": "Absolute path for the output file. Also accepts 'path' as an alias.",
|
|
729
|
+
},
|
|
730
|
+
"path": {
|
|
731
|
+
"type": "string",
|
|
732
|
+
"description": "Alias for output_path. Absolute path for the output file.",
|
|
733
|
+
},
|
|
734
|
+
"template": {
|
|
735
|
+
"type": "string",
|
|
736
|
+
"description": "Optional template path — honored by docx (.docx/.dotx) and pptx (.pptx/.potx) creation: styles, theme, headers/footers and slide layouts load from the file. Ignored by every other format.",
|
|
737
|
+
},
|
|
738
|
+
"options": {
|
|
739
|
+
"type": "object",
|
|
740
|
+
"description": "Optional format-specific options (title, page_size, auto_size, etc.)",
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
"required": ["format", "content"],
|
|
744
|
+
},
|
|
745
|
+
handler=_handle_doc_create,
|
|
746
|
+
description="Create .docx, .xlsx, .pptx, .pdf, .odt, .ods, or .odp documents. For PDFs: pass structured JSON for document-style output, or pass {\"html\": \"<html>...</html>\"} with full CSS (Grid, Flexbox, gradients, themes) for visual/print-ready PDFs via weasyprint. Supports page_size, header, footer, page_numbers options.",
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
registry.register_tool(
|
|
750
|
+
name="doc_edit_pdf",
|
|
751
|
+
toolset="doc-create",
|
|
752
|
+
schema={
|
|
753
|
+
"type": "object",
|
|
754
|
+
"properties": {
|
|
755
|
+
"path": {
|
|
756
|
+
"type": "string",
|
|
757
|
+
"description": "Absolute path to the PDF file.",
|
|
758
|
+
},
|
|
759
|
+
"page": {
|
|
760
|
+
"type": "integer",
|
|
761
|
+
"description": "Page number to edit (1-indexed).",
|
|
762
|
+
"default": 1,
|
|
763
|
+
},
|
|
764
|
+
"instruction": {
|
|
765
|
+
"type": "string",
|
|
766
|
+
"description": "Natural language instruction for the edit.",
|
|
767
|
+
},
|
|
768
|
+
"output_path": {
|
|
769
|
+
"type": "string",
|
|
770
|
+
"description": "Optional output path. Defaults to <original>_edited.pdf",
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
"required": ["path", "instruction"],
|
|
774
|
+
},
|
|
775
|
+
handler=_handle_doc_edit,
|
|
776
|
+
description="Edit a PDF page via local PyMuPDF text search and redaction (no AI service).",
|
|
777
|
+
)
|