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/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
+ )