main
py 1,848 lines 69.1 KB
Raw
1 from __future__ import annotations
2
3 import csv
4 import io
5 import json
6 import re
7 import zipfile
8 from pathlib import Path
9 from typing import Any
10 from xml.sax.saxutils import escape
11 import xml.etree.ElementTree as ET
12
13 from plugins._office.helpers import document_store, pptx_writer
14
15
16 W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
17 A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
18 P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
19 R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
20 CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
21 REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
22 XML_NS = "http://www.w3.org/XML/1998/namespace"
23 ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
24 ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
25 ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
26 ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
27 ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
28 ODS_DIRECT_EDIT_ROW_LIMIT = 10000
29 ODS_DIRECT_EDIT_COLUMN_LIMIT = 1024
30
31 for prefix, namespace in {
32 "w": W_NS,
33 "a": A_NS,
34 "p": P_NS,
35 "r": R_NS,
36 "office": ODF_OFFICE_NS,
37 "text": ODF_TEXT_NS,
38 "table": ODF_TABLE_NS,
39 "draw": ODF_DRAW_NS,
40 "presentation": ODF_PRESENTATION_NS,
41 }.items():
42 ET.register_namespace(prefix, namespace)
43
44
45 def qn(namespace: str, tag: str) -> str:
46 return f"{{{namespace}}}{tag}"
47
48
49 def read_artifact(doc: dict[str, Any], max_chars: int = 12000) -> dict[str, Any]:
50 """Extract compact editable content from an Office artifact."""
51 path = Path(doc["path"])
52 ext = str(doc["extension"]).lower()
53 if ext == "md":
54 raise ValueError("Office artifact reads are not available for Markdown; use text_editor.")
55 if ext == "odt":
56 content = _read_odt(path)
57 elif ext == "ods":
58 content = _read_ods(path)
59 elif ext == "odp":
60 content = _read_odp(path)
61 elif ext == "docx":
62 content = _read_docx(path)
63 elif ext == "xlsx":
64 content = _read_xlsx(path)
65 elif ext == "pptx":
66 content = _read_pptx(path)
67 else:
68 raise ValueError(f"Unsupported document format: {ext}")
69
70 return _trim_payload(content, max_chars=max_chars)
71
72
73 def edit_artifact(
74 doc: dict[str, Any],
75 operation: str = "",
76 content: str = "",
77 find: str = "",
78 replace: str = "",
79 sheet: str = "",
80 cells: Any = None,
81 rows: Any = None,
82 chart: Any = None,
83 slides: Any = None,
84 **kwargs: Any,
85 ) -> tuple[dict[str, Any], dict[str, Any]]:
86 """Apply a direct saved edit to an Office artifact and return updated metadata."""
87 path = Path(doc["path"])
88 ext = str(doc["extension"]).lower()
89 operation, content, find, replace, cells, rows, chart, slides, kwargs = _normalize_edit_inputs(
90 operation=operation,
91 content=content,
92 find=find,
93 replace=replace,
94 cells=cells,
95 rows=rows,
96 chart=chart,
97 slides=slides,
98 kwargs=kwargs,
99 )
100 op = normalize_operation(operation, content=content, find=find, cells=cells, rows=rows, chart=chart, slides=slides)
101 if op in {"append_text", "prepend_text"} and content == "":
102 raise ValueError(f"content is required for {op}")
103 before = path.read_bytes()
104
105 invalidate_sessions = bool(kwargs.pop("invalidate_sessions", False))
106 if ext == "md":
107 raise ValueError("Office artifact edits are not available for Markdown; use text_editor.")
108 elif ext == "odt":
109 updated, details = _edit_odt(before, op, content=content, find=find, replace=replace, **kwargs)
110 elif ext == "ods":
111 updated, details = _edit_ods(before, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, **kwargs)
112 elif ext == "odp":
113 updated, details = _edit_odp(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
114 elif ext == "docx":
115 updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs)
116 elif ext == "xlsx":
117 updated, details = _edit_xlsx(path, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, chart=chart, **kwargs)
118 elif ext == "pptx":
119 updated, details = _edit_pptx(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
120 else:
121 raise ValueError(f"Direct edit is not available for .{ext}.")
122
123 changed = updated != before
124 updated_doc = (
125 document_store.replace_document_bytes(
126 doc["file_id"],
127 updated,
128 actor="office_artifact:edit",
129 invalidate_sessions=invalidate_sessions,
130 )
131 if changed
132 else doc
133 )
134 if changed:
135 _refresh_open_office_sessions(updated_doc["file_id"])
136 preview = read_artifact(updated_doc, max_chars=int(kwargs.get("preview_chars") or 4000))
137 payload = {
138 "ok": True,
139 "action": "edit",
140 "operation": op,
141 "changed": changed,
142 **details,
143 "preview": preview,
144 }
145 return updated_doc, payload
146
147
148 def _normalize_edit_inputs(
149 *,
150 operation: str = "",
151 content: str = "",
152 find: str = "",
153 replace: str = "",
154 cells: Any = None,
155 rows: Any = None,
156 chart: Any = None,
157 slides: Any = None,
158 kwargs: dict[str, Any] | None = None,
159 ) -> tuple[str, str, str, str, Any, Any, Any, Any, dict[str, Any]]:
160 kwargs = dict(kwargs or {})
161 edit_spec = _edit_spec_from_kwargs(kwargs)
162
163 operation = _first_text(
164 operation,
165 edit_spec.get("operation"),
166 edit_spec.get("op"),
167 edit_spec.get("edit"),
168 edit_spec.get("type"),
169 )
170
171 lines = _first_present(
172 edit_spec.get("add_lines"),
173 edit_spec.get("append_lines"),
174 edit_spec.get("lines"),
175 kwargs.get("add_lines"),
176 kwargs.get("append_lines"),
177 )
178 if not operation and lines is not None:
179 operation = "append_text"
180
181 if content == "":
182 content = _text_from_lines(lines)
183 if content == "":
184 content = _first_text(
185 edit_spec.get("content"),
186 edit_spec.get("text"),
187 edit_spec.get("value"),
188 edit_spec.get("body"),
189 kwargs.get("content"),
190 kwargs.get("text"),
191 kwargs.get("value"),
192 kwargs.get("body"),
193 )
194
195 if find == "":
196 find = _first_text(
197 edit_spec.get("find"),
198 edit_spec.get("old_text"),
199 edit_spec.get("old"),
200 kwargs.get("old_text"),
201 kwargs.get("old"),
202 )
203
204 if replace == "":
205 replace = _first_text(
206 edit_spec.get("replace"),
207 edit_spec.get("replacement"),
208 edit_spec.get("new_text"),
209 edit_spec.get("new"),
210 kwargs.get("replacement"),
211 kwargs.get("new_text"),
212 kwargs.get("new"),
213 )
214
215 if replace == "" and _looks_like_replace_operation(operation):
216 replace = _first_text(edit_spec.get("value"), kwargs.get("value"))
217
218 cells = cells if cells is not None else _first_present(edit_spec.get("cells"), kwargs.get("cells"))
219 rows = rows if rows is not None else _first_present(edit_spec.get("rows"), kwargs.get("rows"))
220 chart = chart if chart is not None else _first_present(edit_spec.get("chart"), kwargs.get("chart"))
221 slides = slides if slides is not None else _first_present(edit_spec.get("slides"), kwargs.get("slides"))
222
223 return operation, content, find, replace, cells, rows, chart, slides, kwargs
224
225
226 def _edit_spec_from_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
227 for key in ("edit", "edits", "update", "patch"):
228 spec = _first_mapping(kwargs.get(key))
229 if spec:
230 return spec
231 return {}
232
233
234 def _first_mapping(value: Any) -> dict[str, Any]:
235 if isinstance(value, dict):
236 return value
237 if isinstance(value, list):
238 for item in value:
239 if isinstance(item, dict):
240 return item
241 return {}
242
243
244 def _first_present(*values: Any) -> Any:
245 for value in values:
246 if value is not None:
247 return value
248 return None
249
250
251 def _first_text(*values: Any) -> str:
252 for value in values:
253 text = _text_from_lines(value)
254 if text != "":
255 return text
256 return ""
257
258
259 def _text_from_lines(value: Any) -> str:
260 if value is None:
261 return ""
262 if isinstance(value, list):
263 return "\n".join(str(item) for item in value)
264 return str(value)
265
266
267 def _looks_like_replace_operation(operation: str = "") -> bool:
268 op = str(operation or "").strip().lower().replace("-", "_")
269 return op in {"replace", "replace_text", "patch", "update"}
270
271
272 def _refresh_open_office_sessions(file_id: str) -> None:
273 try:
274 from plugins._desktop.helpers import desktop_session
275
276 desktop_session.get_manager().refresh_document(file_id)
277 except Exception:
278 # Direct artifact edits should never fail just because no Office surface is open.
279 pass
280
281
282 def normalize_operation(
283 operation: str,
284 *,
285 content: str = "",
286 find: str = "",
287 cells: Any = None,
288 rows: Any = None,
289 chart: Any = None,
290 slides: Any = None,
291 ) -> str:
292 op = str(operation or "").strip().lower().replace("-", "_")
293 operation_map = {
294 "patch": "replace_text" if find else "set_text",
295 "update": "replace_text" if find else "set_text",
296 "replace": "replace_text",
297 "append": "append_text",
298 "append_line": "append_text",
299 "append_lines": "append_text",
300 "add_line": "append_text",
301 "add_lines": "append_text",
302 "prepend": "prepend_text",
303 "prepend_line": "prepend_text",
304 "prepend_lines": "prepend_text",
305 "write": "set_text",
306 "set": "set_text",
307 "set_content": "set_text",
308 "set_sheet": "set_rows",
309 "write_sheet": "set_rows",
310 "add_rows": "append_rows",
311 "add_chart": "create_chart",
312 "chart": "create_chart",
313 "insert_chart": "create_chart",
314 "set_chart": "create_chart",
315 "add_slide": "append_slide",
316 "set_deck": "set_slides",
317 }
318 op = operation_map.get(op, op)
319 if op:
320 return op
321 if cells:
322 return "set_cells"
323 if rows:
324 return "append_rows"
325 if chart:
326 return "create_chart"
327 if slides:
328 return "set_slides"
329 if find:
330 return "replace_text"
331 if content:
332 return "set_text"
333 raise ValueError("operation is required")
334
335
336 def _read_odt(path: Path) -> dict[str, Any]:
337 root = _odf_content_root(path)
338 paragraphs = _odf_text_lines(root)
339 headings = [
340 "".join(node.itertext()).strip()
341 for node in root.iter(qn(ODF_TEXT_NS, "h"))
342 if "".join(node.itertext()).strip()
343 ]
344 return {
345 "kind": "document",
346 "format": "odt",
347 "paragraph_count": len(paragraphs),
348 "headings": headings[:40],
349 "text": "\n".join(paragraphs),
350 "paragraphs": paragraphs[:80],
351 }
352
353
354 def _read_ods(path: Path) -> dict[str, Any]:
355 sheets = _ods_sheets_from_bytes(
356 path.read_bytes(),
357 max_rows=ODS_DIRECT_EDIT_ROW_LIMIT,
358 max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT,
359 )
360 return {
361 "kind": "spreadsheet",
362 "format": "ods",
363 "sheet_count": len(sheets),
364 "sheets": [
365 {
366 "name": sheet["name"],
367 "max_row": len(sheet["rows"]),
368 "max_column": max((len(row) for row in sheet["rows"]), default=0),
369 "chart_count": 0,
370 "charts": [],
371 "preview_rows": sheet["rows"][:80],
372 }
373 for sheet in sheets[:8]
374 ],
375 }
376
377
378 def _read_odp(path: Path) -> dict[str, Any]:
379 slides = _odp_text_slides(path.read_bytes())
380 return {
381 "kind": "presentation",
382 "format": "odp",
383 "slide_count": len(slides),
384 "slides": [
385 {
386 "index": index + 1,
387 "title": slide.get("title", ""),
388 "lines": [slide.get("title", ""), *slide.get("bullets", [])],
389 }
390 for index, slide in enumerate(slides[:40])
391 ],
392 }
393
394
395 def _read_docx(path: Path) -> dict[str, Any]:
396 with zipfile.ZipFile(path) as archive:
397 xml = archive.read("word/document.xml")
398 root = ET.fromstring(xml)
399 paragraphs = []
400 for paragraph in root.iter(qn(W_NS, "p")):
401 text = "".join(node.text or "" for node in paragraph.iter(qn(W_NS, "t")))
402 if text.strip():
403 paragraphs.append(text)
404 return {
405 "kind": "document",
406 "paragraph_count": len(paragraphs),
407 "text": "\n".join(paragraphs),
408 "paragraphs": paragraphs[:80],
409 }
410
411
412 def _read_xlsx(path: Path) -> dict[str, Any]:
413 openpyxl = _require_openpyxl()
414 workbook = openpyxl.load_workbook(path, data_only=False)
415 sheets = []
416 for worksheet in workbook.worksheets[:8]:
417 rows = []
418 max_row = min(worksheet.max_row or 0, 80)
419 max_col = min(worksheet.max_column or 0, 30)
420 for row in worksheet.iter_rows(min_row=1, max_row=max_row, max_col=max_col, values_only=True):
421 values = ["" if value is None else value for value in row]
422 if any(str(value).strip() for value in values):
423 rows.append(values)
424 charts = [_chart_summary(chart) for chart in getattr(worksheet, "_charts", [])[:20]]
425 sheets.append({
426 "name": worksheet.title,
427 "max_row": worksheet.max_row,
428 "max_column": worksheet.max_column,
429 "chart_count": len(getattr(worksheet, "_charts", [])),
430 "charts": charts,
431 "preview_rows": rows,
432 })
433 return {
434 "kind": "spreadsheet",
435 "sheet_count": len(workbook.worksheets),
436 "sheets": sheets,
437 }
438
439
440 def _read_pptx(path: Path) -> dict[str, Any]:
441 slides = []
442 with zipfile.ZipFile(path) as archive:
443 for name in _slide_names(archive):
444 root = ET.fromstring(archive.read(name))
445 lines = []
446 for paragraph in root.iter(qn(A_NS, "p")):
447 text = "".join(node.text or "" for node in paragraph.iter(qn(A_NS, "t"))).strip()
448 if text:
449 lines.append(text)
450 slides.append({
451 "index": len(slides) + 1,
452 "title": lines[0] if lines else "",
453 "lines": lines,
454 })
455 return {
456 "kind": "presentation",
457 "slide_count": len(slides),
458 "slides": slides[:40],
459 }
460
461
462 def _edit_odt(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
463 if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
464 raise ValueError(f"Unsupported ODT operation: {op}")
465
466 paragraphs = _odf_text_lines(ET.fromstring(_zip_member(before, "content.xml")))
467 if op == "set_text":
468 lines = _text_lines(content)
469 return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
470 if op == "append_text":
471 lines = [*paragraphs, *_text_lines(content)]
472 return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
473 if op == "prepend_text":
474 lines = [*_text_lines(content), *paragraphs]
475 return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
476
477 if not find:
478 raise ValueError("find is required for replace_text")
479 replacement = "" if op == "delete_text" else replace
480 joined, count = _replace_limited("\n".join(paragraphs), find, replacement, _int_or_none(kwargs.get("count")))
481 if count == 0:
482 return before, {"replacements": count}
483 return document_store.odt_bytes_from_paragraphs(joined.splitlines()), {"replacements": count}
484
485
486 def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
487 if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
488 raise ValueError(f"Unsupported DOCX operation: {op}")
489
490 with zipfile.ZipFile(io.BytesIO(before)) as archive:
491 files = {info.filename: archive.read(info.filename) for info in archive.infolist()}
492 root = ET.fromstring(files["word/document.xml"])
493
494 if op == "replace_text" or op == "delete_text":
495 if not find:
496 raise ValueError("find is required for replace_text")
497 replacement = "" if op == "delete_text" else replace
498 count = _replace_text_in_paragraphs(
499 root,
500 paragraph_tag=qn(W_NS, "p"),
501 text_tag=qn(W_NS, "t"),
502 set_text=_set_word_paragraph_text,
503 find=find,
504 replacement=replacement,
505 limit=_int_or_none(kwargs.get("count")),
506 )
507 details = {"replacements": count}
508 if count == 0:
509 return before, details
510 else:
511 lines = _text_lines(content)
512 body = root.find(f".//{qn(W_NS, 'body')}")
513 if body is None:
514 raise ValueError("DOCX document body not found")
515 paragraphs = [_word_paragraph(line) for line in lines]
516 if op == "set_text":
517 sect_pr = [child for child in list(body) if child.tag == qn(W_NS, "sectPr")]
518 for child in list(body):
519 body.remove(child)
520 for paragraph in paragraphs:
521 body.append(paragraph)
522 for child in sect_pr:
523 body.append(child)
524 elif op == "append_text":
525 insert_at = len(body)
526 for idx, child in enumerate(list(body)):
527 if child.tag == qn(W_NS, "sectPr"):
528 insert_at = idx
529 break
530 for paragraph in reversed(paragraphs):
531 body.insert(insert_at, paragraph)
532 elif op == "prepend_text":
533 for paragraph in reversed(paragraphs):
534 body.insert(0, paragraph)
535 details = {"paragraphs_written": len(paragraphs)}
536
537 files["word/document.xml"] = _xml_bytes(root)
538 return _zip_from_existing(files), details
539
540
541 def _edit_ods(
542 before: bytes,
543 op: str,
544 *,
545 content: str = "",
546 find: str = "",
547 replace: str = "",
548 sheet: str = "",
549 cells: Any = None,
550 rows: Any = None,
551 **kwargs: Any,
552 ) -> tuple[bytes, dict[str, Any]]:
553 if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text"}:
554 raise ValueError(f"Unsupported ODS operation: {op}")
555
556 sheets = _ods_sheets_from_bytes(
557 before,
558 max_rows=ODS_DIRECT_EDIT_ROW_LIMIT,
559 max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT,
560 strict_limits=True,
561 )
562 if not sheets:
563 sheets = [{"name": "Sheet1", "rows": []}]
564 worksheet = _ods_sheet(sheets, sheet)
565 details: dict[str, Any] = {"sheet": worksheet["name"]}
566
567 if op in {"set_text", "set_rows"}:
568 parsed_rows = _normalize_rows(rows if rows is not None else content)
569 worksheet["rows"] = parsed_rows
570 details["rows_written"] = len(parsed_rows)
571 elif op in {"append_text", "append_rows"}:
572 parsed_rows = _normalize_rows(rows if rows is not None else content)
573 worksheet["rows"].extend(parsed_rows)
574 details["rows_appended"] = len(parsed_rows)
575 details["start_row"] = max(len(worksheet["rows"]) - len(parsed_rows) + 1, 1)
576 elif op == "set_cells":
577 assignments = _normalize_cells(cells, default_sheet=worksheet["name"])
578 for sheet_name, cell, value in assignments:
579 target = _ods_sheet(sheets, sheet_name)
580 row_idx, col_idx = _cell_indices(cell)
581 _set_matrix_value(target["rows"], row_idx, col_idx, value)
582 details["cells_written"] = len(assignments)
583 else:
584 if not find:
585 raise ValueError("find is required for replace_text")
586 replacement = "" if op == "delete_text" else replace
587 count = 0
588 limit = _int_or_none(kwargs.get("count"))
589 for item in sheets:
590 for row_idx, row in enumerate(item["rows"]):
591 for col_idx, value in enumerate(row):
592 if not isinstance(value, str) or find not in value:
593 continue
594 remaining = None if limit is None else max(limit - count, 0)
595 if remaining == 0:
596 break
597 row[col_idx], replaced = _replace_limited(value, find, replacement, remaining)
598 count += replaced
599 if limit is not None and count >= limit:
600 break
601 if limit is not None and count >= limit:
602 break
603 details["replacements"] = count
604 if count == 0:
605 return before, details
606
607 return document_store.ods_bytes_from_sheets(sheets), details
608
609
610 def _edit_xlsx(
611 path: Path,
612 op: str,
613 *,
614 content: str = "",
615 find: str = "",
616 replace: str = "",
617 sheet: str = "",
618 cells: Any = None,
619 rows: Any = None,
620 chart: Any = None,
621 **kwargs: Any,
622 ) -> tuple[bytes, dict[str, Any]]:
623 if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text", "create_chart"}:
624 raise ValueError(f"Unsupported XLSX operation: {op}")
625 openpyxl = _require_openpyxl()
626 workbook = openpyxl.load_workbook(path)
627 worksheet = _worksheet(workbook, sheet)
628
629 details: dict[str, Any] = {"sheet": worksheet.title}
630 if op in {"set_text", "set_rows"}:
631 parsed_rows = _normalize_rows(rows if rows is not None else content)
632 _clear_worksheet(worksheet)
633 _write_rows(worksheet, parsed_rows, start_row=1)
634 details["rows_written"] = len(parsed_rows)
635 elif op in {"append_text", "append_rows"}:
636 parsed_rows = _normalize_rows(rows if rows is not None else content)
637 start_row = max((worksheet.max_row or 0) + 1, 1)
638 _write_rows(worksheet, parsed_rows, start_row=start_row)
639 details["rows_appended"] = len(parsed_rows)
640 details["start_row"] = start_row
641 elif op == "set_cells":
642 assignments = _normalize_cells(cells, default_sheet=worksheet.title)
643 for sheet_name, cell, value in assignments:
644 target = _worksheet(workbook, sheet_name)
645 target[cell] = value
646 details["cells_written"] = len(assignments)
647 elif op in {"replace_text", "delete_text"}:
648 if not find:
649 raise ValueError("find is required for replace_text")
650 replacement = "" if op == "delete_text" else replace
651 count = 0
652 limit = _int_or_none(kwargs.get("count"))
653 for target in workbook.worksheets:
654 for row in target.iter_rows():
655 for cell in row:
656 if not isinstance(cell.value, str) or find not in cell.value:
657 continue
658 remaining = None if limit is None else max(limit - count, 0)
659 if remaining == 0:
660 break
661 cell.value, replaced = _replace_limited(cell.value, find, replacement, remaining)
662 count += replaced
663 if limit is not None and count >= limit:
664 break
665 if limit is not None and count >= limit:
666 break
667 details["replacements"] = count
668 if count == 0:
669 return path.read_bytes(), details
670 elif op == "create_chart":
671 chart_details = []
672 for chart_spec in _normalize_chart_specs(chart, kwargs):
673 chart_details.append(_create_xlsx_chart(workbook, worksheet, chart_spec))
674 details["charts_created"] = len(chart_details)
675 details["charts"] = chart_details
676
677 buffer = io.BytesIO()
678 workbook.save(buffer)
679 return buffer.getvalue(), details
680
681
682 _CHART_SPEC_KEYS = {
683 "anchor",
684 "categories",
685 "chart_type",
686 "close",
687 "data_range",
688 "fields",
689 "from_rows",
690 "height",
691 "high",
692 "include_headers",
693 "labels",
694 "legend",
695 "low",
696 "open",
697 "position",
698 "replace_existing",
699 "series",
700 "sheet",
701 "style",
702 "title",
703 "titles_from_data",
704 "type",
705 "values",
706 "width",
707 "x_axis_title",
708 "xvalues",
709 "y_axis_title",
710 "yvalues",
711 }
712
713 _CHART_TYPE_NORMALIZATIONS = {
714 "area": "area",
715 "bar": "bar",
716 "candlestick": "stock",
717 "col": "column",
718 "column": "column",
719 "columns": "column",
720 "line": "line",
721 "ohlc": "stock",
722 "pie": "pie",
723 "scatter": "scatter",
724 "stock": "stock",
725 }
726
727
728 def _normalize_chart_specs(chart: Any, kwargs: dict[str, Any]) -> list[dict[str, Any]]:
729 parsed = _parse_chart_value(chart)
730 if isinstance(parsed, list):
731 if not parsed:
732 raise ValueError("chart list must include at least one chart spec")
733 return [_normalize_chart_spec(item, {}) for item in parsed]
734 if parsed is None:
735 parsed = {}
736 if not isinstance(parsed, dict):
737 raise ValueError("chart must be an object, JSON object, or list of chart objects")
738 return [_normalize_chart_spec(parsed, kwargs)]
739
740
741 def _parse_chart_value(value: Any) -> Any:
742 if value is None or value == "":
743 return None
744 if isinstance(value, str):
745 stripped = value.strip()
746 if not stripped:
747 return None
748 if stripped.startswith("{") or stripped.startswith("["):
749 return json.loads(stripped)
750 return {"type": stripped}
751 return value
752
753
754 def _normalize_chart_spec(value: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
755 if isinstance(value, str):
756 value = _parse_chart_value(value)
757 if value is None:
758 value = {}
759 if not isinstance(value, dict):
760 raise ValueError("each chart spec must be an object")
761
762 spec = dict(value)
763 explicit_include_headers = "include_headers" in spec or "titles_from_data" in spec
764 for key in _CHART_SPEC_KEYS:
765 if key in kwargs and kwargs[key] not in (None, ""):
766 spec[key] = kwargs[key]
767 if key in {"include_headers", "titles_from_data"}:
768 explicit_include_headers = True
769
770 explicit_type = bool(spec.get("type") or spec.get("chart_type"))
771 chart_type = str(spec.get("type") or spec.get("chart_type") or "").strip().lower().replace("-", "_")
772 if chart_type:
773 chart_type = _CHART_TYPE_NORMALIZATIONS.get(chart_type, chart_type)
774 spec["type"] = chart_type
775 spec["_explicit_type"] = explicit_type
776 spec["position"] = str(spec.get("position") or spec.get("anchor") or "H2")
777 spec["include_headers"] = _bool_value(spec.get("include_headers", spec.get("titles_from_data")), default=True)
778 spec["_include_headers_explicit"] = explicit_include_headers
779 spec["from_rows"] = _bool_value(spec.get("from_rows"), default=False)
780 spec["replace_existing"] = _bool_value(spec.get("replace_existing"), default=False)
781 spec["width"] = _float_or_default(spec.get("width"), 18.0)
782 spec["height"] = _float_or_default(spec.get("height"), 10.0)
783 return spec
784
785
786 def _create_xlsx_chart(workbook: Any, default_worksheet: Any, spec: dict[str, Any]) -> dict[str, Any]:
787 openpyxl = _require_openpyxl()
788 worksheet = _worksheet(workbook, str(spec.get("sheet") or default_worksheet.title))
789 chart_type = spec["type"] or _infer_default_chart_type(worksheet)
790 if chart_type not in _CHART_TYPE_NORMALIZATIONS.values():
791 raise ValueError(f"Unsupported XLSX chart type: {chart_type}")
792
793 if spec["replace_existing"]:
794 charts_removed = len(getattr(worksheet, "_charts", []))
795 worksheet._charts = []
796 else:
797 charts_removed = 0
798
799 if chart_type == "stock":
800 chart, data_range, categories = _stock_chart(openpyxl, workbook, worksheet, spec)
801 elif chart_type == "scatter":
802 chart, data_range, categories = _scatter_chart(openpyxl, workbook, worksheet, spec)
803 else:
804 chart, data_range, categories = _standard_chart(openpyxl, workbook, worksheet, spec, chart_type)
805
806 _apply_chart_options(chart, spec)
807 worksheet.add_chart(chart, spec["position"])
808 return {
809 "type": chart_type,
810 "title": str(spec.get("title") or ""),
811 "sheet": worksheet.title,
812 "position": spec["position"],
813 "data_range": data_range,
814 "categories": categories,
815 "series_count": len(getattr(chart, "series", [])),
816 "charts_removed": charts_removed,
817 }
818
819
820 def _standard_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any], chart_type: str) -> tuple[Any, str, str]:
821 chart_classes = {
822 "area": openpyxl.chart.AreaChart,
823 "bar": openpyxl.chart.BarChart,
824 "column": openpyxl.chart.BarChart,
825 "line": openpyxl.chart.LineChart,
826 "pie": openpyxl.chart.PieChart,
827 }
828 chart = chart_classes[chart_type]()
829 if chart_type == "bar":
830 chart.type = "bar"
831 elif chart_type == "column":
832 chart.type = "col"
833
834 include_headers = bool(spec["include_headers"])
835 categories = str(spec.get("categories") or spec.get("labels") or "")
836 if spec.get("series"):
837 data_range = _add_explicit_series(openpyxl, chart, workbook, worksheet, spec, validate_numeric=True)
838 else:
839 range_value = spec.get("values") or spec.get("data_range") or _default_data_range(worksheet, chart_type, include_headers)
840 include_headers = _include_headers_for_range(spec, range_value)
841 data_ref, data_sheet, bounds, data_range = _reference_from_range(openpyxl, workbook, worksheet, range_value)
842 _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=data_range)
843 chart.add_data(data_ref, titles_from_data=include_headers, from_rows=bool(spec["from_rows"]))
844
845 if not categories:
846 categories = _default_category_range(worksheet, include_headers=include_headers)
847 if categories:
848 categories_ref, _, _, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
849 chart.set_categories(categories_ref)
850 return chart, data_range, categories
851
852
853 def _stock_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any]) -> tuple[Any, str, str]:
854 chart = openpyxl.chart.StockChart()
855 field_ranges = _stock_field_ranges(spec)
856
857 if field_ranges:
858 data_labels = []
859 for label in ("open", "high", "low", "close"):
860 include_headers = _include_headers_for_range(spec, field_ranges[label])
861 series_ref, data_sheet, bounds, label_range = _reference_from_range(openpyxl, workbook, worksheet, field_ranges[label])
862 _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=label)
863 chart.series.append(openpyxl.chart.Series(series_ref, title_from_data=include_headers))
864 data_labels.append(label_range)
865 data_range = ", ".join(data_labels)
866 elif spec.get("series"):
867 include_headers = bool(spec["include_headers"])
868 data_range = _add_explicit_series(openpyxl, chart, workbook, worksheet, spec, expected_count=4, validate_numeric=True)
869 else:
870 include_headers = bool(spec["include_headers"])
871 range_value = spec.get("data_range") or _default_data_range(worksheet, "stock", include_headers)
872 include_headers = _include_headers_for_range(spec, range_value)
873 _, data_sheet, bounds, range_label = _reference_from_range(openpyxl, workbook, worksheet, range_value)
874 min_col, min_row, max_col, max_row = bounds
875 columns = list(range(min_col, max_col + 1))
876 if len(columns) > 4 and _looks_like_category_header(data_sheet.cell(row=min_row, column=min_col).value):
877 columns = columns[1:5]
878 else:
879 columns = columns[:4]
880 if len(columns) != 4:
881 raise ValueError("stock charts require exactly four Open, High, Low, Close data series")
882 _validate_stock_headers(data_sheet, columns, min_row, include_headers=include_headers)
883 for column in columns:
884 _validate_numeric_series(data_sheet, (column, min_row, column, max_row), include_headers=include_headers, label=data_sheet.cell(row=min_row, column=column).value or _column_letter(column))
885 series_ref = openpyxl.chart.Reference(data_sheet, min_col=column, min_row=min_row, max_col=column, max_row=max_row)
886 chart.series.append(openpyxl.chart.Series(series_ref, title_from_data=include_headers))
887 data_range = range_label
888
889 categories = str(spec.get("categories") or spec.get("labels") or _default_category_range(worksheet, include_headers=bool(spec["include_headers"])))
890 if categories:
891 categories_ref, _, _, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
892 chart.set_categories(categories_ref)
893 chart.hiLowLines = openpyxl.chart.axis.ChartLines()
894 chart.upDownBars = openpyxl.chart.updown_bars.UpDownBars()
895 return chart, data_range, categories
896
897
898 def _scatter_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any]) -> tuple[Any, str, str]:
899 chart = openpyxl.chart.ScatterChart()
900 include_headers = bool(spec["include_headers"])
901 categories = str(spec.get("xvalues") or spec.get("categories") or _default_category_range(worksheet, include_headers=include_headers))
902 x_ref, x_sheet, x_bounds, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
903 if include_headers and x_bounds[1] == 1 and x_bounds[3] > 1:
904 x_ref = openpyxl.chart.Reference(x_sheet, min_col=x_bounds[0], min_row=2, max_col=x_bounds[2], max_row=x_bounds[3])
905
906 data_ranges = []
907 series_items = _series_items(spec)
908 if series_items:
909 for item in series_items:
910 values_ref, title, data_range = _series_values_reference(openpyxl, workbook, worksheet, item, include_headers=include_headers, validate_numeric=True)
911 xvalues = item.get("xvalues") or item.get("x") or item.get("categories")
912 if xvalues:
913 item_x_ref, item_x_sheet, item_x_bounds, _ = _reference_from_range(openpyxl, workbook, worksheet, xvalues)
914 if include_headers and item_x_bounds[1] == 1 and item_x_bounds[3] > 1:
915 item_x_ref = openpyxl.chart.Reference(item_x_sheet, min_col=item_x_bounds[0], min_row=2, max_col=item_x_bounds[2], max_row=item_x_bounds[3])
916 else:
917 item_x_ref = x_ref
918 chart.series.append(openpyxl.chart.Series(values_ref, xvalues=item_x_ref, title=title))
919 data_ranges.append(data_range)
920 else:
921 range_value = spec.get("yvalues") or spec.get("values") or spec.get("data_range") or _default_data_range(worksheet, "scatter", include_headers)
922 _, data_sheet, bounds, data_range = _reference_from_range(openpyxl, workbook, worksheet, range_value)
923 min_col, min_row, max_col, max_row = bounds
924 first_row = min_row + 1 if include_headers and min_row == 1 and max_row > 1 else min_row
925 for column in range(min_col, max_col + 1):
926 title = data_sheet.cell(row=min_row, column=column).value if first_row > min_row else None
927 _validate_numeric_series(data_sheet, (column, min_row, column, max_row), include_headers=include_headers, label=title or _column_letter(column))
928 y_ref = openpyxl.chart.Reference(data_sheet, min_col=column, min_row=first_row, max_col=column, max_row=max_row)
929 chart.series.append(openpyxl.chart.Series(y_ref, xvalues=x_ref, title=str(title) if title is not None else None))
930 return chart, ", ".join(data_ranges) if data_ranges else data_range, categories
931
932
933 def _add_explicit_series(
934 openpyxl: Any,
935 chart: Any,
936 workbook: Any,
937 worksheet: Any,
938 spec: dict[str, Any],
939 expected_count: int | None = None,
940 validate_numeric: bool = False,
941 ) -> str:
942 include_headers = bool(spec["include_headers"])
943 ranges = []
944 for item in _series_items(spec):
945 values_ref, title, label = _series_values_reference(openpyxl, workbook, worksheet, item, include_headers=include_headers, validate_numeric=validate_numeric)
946 if title:
947 chart.series.append(openpyxl.chart.Series(values_ref, title=title))
948 else:
949 chart.series.append(openpyxl.chart.Series(values_ref, title_from_data=include_headers))
950 ranges.append(label)
951 if expected_count is not None and len(ranges) != expected_count:
952 raise ValueError(f"chart requires exactly {expected_count} series")
953 return ", ".join(ranges)
954
955
956 def _series_items(spec: dict[str, Any]) -> list[dict[str, Any]]:
957 raw = spec.get("series") or []
958 if isinstance(raw, str):
959 raw = json.loads(raw)
960 if not isinstance(raw, list):
961 raise ValueError("chart series must be a list")
962 items = []
963 for item in raw:
964 if isinstance(item, str):
965 items.append({"values": item})
966 elif isinstance(item, dict):
967 items.append(item)
968 else:
969 raise ValueError("chart series entries must be objects or range strings")
970 return items
971
972
973 def _series_values_reference(
974 openpyxl: Any,
975 workbook: Any,
976 worksheet: Any,
977 item: dict[str, Any],
978 *,
979 include_headers: bool,
980 validate_numeric: bool = False,
981 ) -> tuple[Any, str | None, str]:
982 values = item.get("values") or item.get("range") or item.get("yvalues") or item.get("y")
983 if not values:
984 raise ValueError("chart series entries require values or range")
985 ref, data_sheet, bounds, label = _reference_from_range(openpyxl, workbook, worksheet, values)
986 title = item.get("title") or item.get("name")
987 min_col, min_row, max_col, max_row = bounds
988 if include_headers and min_row == 1 and max_col == min_col and max_row > min_row:
989 title = title if title is not None else data_sheet.cell(row=min_row, column=min_col).value
990 ref = openpyxl.chart.Reference(data_sheet, min_col=min_col, min_row=min_row + 1, max_col=max_col, max_row=max_row)
991 if validate_numeric:
992 _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=title or label)
993 return ref, str(title) if title is not None else None, label
994
995
996 def _stock_field_ranges(spec: dict[str, Any]) -> dict[str, Any]:
997 fields = spec.get("fields") or {}
998 if isinstance(fields, str):
999 fields = json.loads(fields)
1000 if not isinstance(fields, dict):
1001 raise ValueError("stock chart fields must be an object")
1002 result = {}
1003 for label in ("open", "high", "low", "close"):
1004 value = spec.get(label) or fields.get(label)
1005 if value:
1006 result[label] = value
1007 if result and set(result) != {"open", "high", "low", "close"}:
1008 raise ValueError("stock chart fields must include open, high, low, and close")
1009 return result
1010
1011
1012 def _reference_from_range(openpyxl: Any, workbook: Any, default_worksheet: Any, value: Any) -> tuple[Any, Any, tuple[int, int, int, int], str]:
1013 sheet_name, cell_range = _split_range_ref(value, default_worksheet.title)
1014 worksheet = _worksheet(workbook, sheet_name)
1015 min_col, min_row, max_col, max_row = openpyxl.utils.cell.range_boundaries(cell_range)
1016 reference = openpyxl.chart.Reference(worksheet, min_col=min_col, min_row=min_row, max_col=max_col, max_row=max_row)
1017 return reference, worksheet, (min_col, min_row, max_col, max_row), _range_label(openpyxl, worksheet.title, min_col, min_row, max_col, max_row)
1018
1019
1020 def _split_range_ref(value: Any, default_sheet: str) -> tuple[str, str]:
1021 if isinstance(value, dict):
1022 sheet = str(value.get("sheet") or default_sheet)
1023 min_cell = value.get("range") or value.get("ref")
1024 if min_cell:
1025 return _split_range_ref(str(min_cell), sheet)
1026 min_col = value.get("min_col")
1027 min_row = value.get("min_row")
1028 max_col = value.get("max_col", min_col)
1029 max_row = value.get("max_row", min_row)
1030 if min_col is None or min_row is None:
1031 raise ValueError("range objects require range/ref or min_col and min_row")
1032 return sheet, f"{_cell_ref(min_col, min_row)}:{_cell_ref(max_col, max_row)}"
1033 ref = str(value or "").strip()
1034 if not ref:
1035 raise ValueError("chart range is required")
1036 if "!" not in ref:
1037 return default_sheet, ref
1038 sheet, cell_range = ref.rsplit("!", 1)
1039 return sheet.strip().strip("'") or default_sheet, cell_range
1040
1041
1042 def _range_label(openpyxl: Any, sheet_title: str, min_col: int, min_row: int, max_col: int, max_row: int) -> str:
1043 start = f"{openpyxl.utils.cell.get_column_letter(min_col)}{min_row}"
1044 end = f"{openpyxl.utils.cell.get_column_letter(max_col)}{max_row}"
1045 sheet = sheet_title if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", sheet_title) else f"'{sheet_title}'"
1046 return f"{sheet}!{start}:{end}"
1047
1048
1049 def _include_headers_for_range(spec: dict[str, Any], range_value: Any) -> bool:
1050 include_headers = bool(spec["include_headers"])
1051 if spec.get("_include_headers_explicit"):
1052 return include_headers
1053 try:
1054 _, cell_range = _split_range_ref(range_value, "")
1055 _, min_row, _, _ = __import__("openpyxl").utils.cell.range_boundaries(cell_range)
1056 except Exception:
1057 return include_headers
1058 return include_headers and min_row == 1
1059
1060
1061 def _validate_stock_headers(data_sheet: Any, columns: list[int], min_row: int, *, include_headers: bool) -> None:
1062 if not include_headers or min_row != 1:
1063 return
1064 expected = ["open", "high", "low", "close"]
1065 found = [str(data_sheet.cell(row=min_row, column=column).value or "").strip().lower() for column in columns]
1066 if found != expected:
1067 raise ValueError(f"stock charts require Open, High, Low, Close columns in order; found {found}")
1068
1069
1070 def _validate_numeric_series(data_sheet: Any, bounds: tuple[int, int, int, int], *, include_headers: bool, label: Any) -> None:
1071 min_col, min_row, max_col, max_row = bounds
1072 start_row = min_row + 1 if include_headers and min_row == 1 and max_row > min_row else min_row
1073 values = [
1074 data_sheet.cell(row=row, column=column).value
1075 for column in range(min_col, max_col + 1)
1076 for row in range(start_row, max_row + 1)
1077 ]
1078 numeric_count = sum(1 for value in values if isinstance(value, (int, float)) and not isinstance(value, bool))
1079 if numeric_count == 0:
1080 name = str(label or _range_label(__import__("openpyxl"), data_sheet.title, min_col, min_row, max_col, max_row))
1081 raise ValueError(f"chart series '{name}' has no numeric data")
1082
1083
1084 def _default_category_range(worksheet: Any, *, include_headers: bool) -> str:
1085 if (worksheet.max_column or 0) < 2 or (worksheet.max_row or 0) < 2:
1086 return ""
1087 first_row_is_header = _looks_like_category_header(worksheet.cell(row=1, column=1).value)
1088 return f"A{2 if include_headers or first_row_is_header else 1}:A{worksheet.max_row}"
1089
1090
1091 def _default_data_range(worksheet: Any, chart_type: str, include_headers: bool) -> str:
1092 max_row = worksheet.max_row or 1
1093 max_col = worksheet.max_column or 1
1094 if chart_type == "stock":
1095 if max_col < 5 or max_row < 2:
1096 raise ValueError("stock charts need Date, Open, High, Low, Close columns or explicit ranges")
1097 return f"B{1 if include_headers else 2}:E{max_row}"
1098 if chart_type == "pie" and max_col >= 2:
1099 return f"B{1 if include_headers else 2}:B{max_row}"
1100 start_col = 2 if max_col >= 2 else 1
1101 return f"{_column_letter(start_col)}{1 if include_headers else 2}:{_column_letter(max_col)}{max_row}"
1102
1103
1104 def _cell_ref(column: Any, row: Any) -> str:
1105 return f"{_column_letter(column)}{int(row)}"
1106
1107
1108 def _column_letter(column: Any) -> str:
1109 if isinstance(column, str) and column.isalpha():
1110 return column.upper()
1111 return __import__("openpyxl").utils.cell.get_column_letter(int(column))
1112
1113
1114 def _infer_default_chart_type(worksheet: Any) -> str:
1115 headers = [str(worksheet.cell(row=1, column=column).value or "").strip().lower() for column in range(1, (worksheet.max_column or 0) + 1)]
1116 if {"open", "high", "low", "close"}.issubset(set(headers)):
1117 return "stock"
1118 return "line"
1119
1120
1121 def _looks_like_category_header(value: Any) -> bool:
1122 return str(value or "").strip().lower() in {"date", "time", "category", "label", "month", "year"}
1123
1124
1125 def _apply_chart_options(chart: Any, spec: dict[str, Any]) -> None:
1126 if spec.get("title"):
1127 chart.title = str(spec["title"])
1128 if spec.get("style") not in (None, ""):
1129 chart.style = int(spec["style"])
1130 chart.width = spec["width"]
1131 chart.height = spec["height"]
1132 if _bool_value(spec.get("legend"), default=True) is False:
1133 chart.legend = None
1134 if spec.get("x_axis_title") and hasattr(chart, "x_axis"):
1135 chart.x_axis.title = str(spec["x_axis_title"])
1136 if spec.get("y_axis_title") and hasattr(chart, "y_axis"):
1137 chart.y_axis.title = str(spec["y_axis_title"])
1138
1139
1140 def _chart_summary(chart: Any) -> dict[str, Any]:
1141 return {
1142 "type": _chart_kind(chart),
1143 "title": _chart_title(chart),
1144 "anchor": _chart_anchor(chart),
1145 "series_count": len(getattr(chart, "series", [])),
1146 }
1147
1148
1149 def _chart_kind(chart: Any) -> str:
1150 name = chart.__class__.__name__.replace("Chart", "").lower()
1151 return {"bar": "bar_or_column", "stock": "stock"}.get(name, name)
1152
1153
1154 def _chart_title(chart: Any) -> str:
1155 title = getattr(chart, "title", None)
1156 if title is None or isinstance(title, str):
1157 return title or ""
1158 try:
1159 paragraphs = title.tx.rich.p
1160 parts = []
1161 for paragraph in paragraphs:
1162 for run in paragraph.r:
1163 if run.t:
1164 parts.append(run.t)
1165 return "".join(parts)
1166 except Exception:
1167 return ""
1168
1169
1170 def _chart_anchor(chart: Any) -> str:
1171 openpyxl = _require_openpyxl()
1172 anchor = getattr(chart, "anchor", "")
1173 if isinstance(anchor, str):
1174 return anchor
1175 marker = getattr(anchor, "_from", None)
1176 if marker is None:
1177 return ""
1178 return f"{openpyxl.utils.cell.get_column_letter(marker.col + 1)}{marker.row + 1}"
1179
1180
1181 def _bool_value(value: Any, default: bool = False) -> bool:
1182 if value in (None, ""):
1183 return default
1184 if isinstance(value, bool):
1185 return value
1186 if isinstance(value, (int, float)):
1187 return bool(value)
1188 return str(value).strip().lower() not in {"0", "false", "no", "off", "none"}
1189
1190
1191 def _float_or_default(value: Any, default: float) -> float:
1192 if value in (None, ""):
1193 return default
1194 try:
1195 return float(value)
1196 except (TypeError, ValueError):
1197 return default
1198
1199
1200 def _edit_pptx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
1201 if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}:
1202 raise ValueError(f"Unsupported PPTX operation: {op}")
1203
1204 if op in {"set_text", "set_slides"}:
1205 parsed_slides = _normalize_slides(slides if slides is not None else content)
1206 return _pptx_from_slides(parsed_slides), {"slides_written": len(parsed_slides)}
1207
1208 if op in {"append_text", "append_slide"}:
1209 existing = _pptx_text_slides(before)
1210 existing.extend(_normalize_slides(slides if slides is not None else content))
1211 return _pptx_from_slides(existing), {"slides_written": len(existing)}
1212
1213 with zipfile.ZipFile(io.BytesIO(before)) as archive:
1214 files = {info.filename: archive.read(info.filename) for info in archive.infolist()}
1215 if not find:
1216 raise ValueError("find is required for replace_text")
1217 replacement = "" if op == "delete_text" else replace
1218 count = 0
1219 limit = _int_or_none(kwargs.get("count"))
1220 for name in sorted([name for name in files if name.startswith("ppt/slides/slide") and name.endswith(".xml")], key=_natural_key):
1221 root = ET.fromstring(files[name])
1222 count += _replace_text_in_paragraphs(
1223 root,
1224 paragraph_tag=qn(A_NS, "p"),
1225 text_tag=qn(A_NS, "t"),
1226 set_text=_set_drawing_paragraph_text,
1227 find=find,
1228 replacement=replacement,
1229 limit=None if limit is None else max(limit - count, 0),
1230 )
1231 files[name] = _xml_bytes(root)
1232 if limit is not None and count >= limit:
1233 break
1234 if count == 0:
1235 return before, {"replacements": count}
1236 return _zip_from_existing(files), {"replacements": count}
1237
1238
1239 def _edit_odp(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
1240 if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}:
1241 raise ValueError(f"Unsupported ODP operation: {op}")
1242
1243 if op in {"set_text", "set_slides"}:
1244 parsed_slides = _normalize_slides(slides if slides is not None else content)
1245 return document_store.odp_bytes_from_slides(parsed_slides), {"slides_written": len(parsed_slides)}
1246
1247 existing = _odp_text_slides(before)
1248 if op in {"append_text", "append_slide"}:
1249 existing.extend(_normalize_slides(slides if slides is not None else content))
1250 return document_store.odp_bytes_from_slides(existing), {"slides_written": len(existing)}
1251
1252 if not find:
1253 raise ValueError("find is required for replace_text")
1254 replacement = "" if op == "delete_text" else replace
1255 count = 0
1256 limit = _int_or_none(kwargs.get("count"))
1257 for slide in existing:
1258 title, title_count = _replace_limited(
1259 str(slide.get("title") or ""),
1260 find,
1261 replacement,
1262 None if limit is None else max(limit - count, 0),
1263 )
1264 if title_count:
1265 slide["title"] = title
1266 count += title_count
1267 if limit is not None and count >= limit:
1268 break
1269 bullets = []
1270 for bullet in slide.get("bullets") or []:
1271 updated, replaced = _replace_limited(
1272 str(bullet),
1273 find,
1274 replacement,
1275 None if limit is None else max(limit - count, 0),
1276 )
1277 bullets.append(updated)
1278 count += replaced
1279 if limit is not None and count >= limit:
1280 bullets.extend(slide.get("bullets", [])[len(bullets):])
1281 break
1282 slide["bullets"] = bullets
1283 if limit is not None and count >= limit:
1284 break
1285 if count == 0:
1286 return before, {"replacements": count}
1287 return document_store.odp_bytes_from_slides(existing), {"replacements": count}
1288
1289
1290 def _replace_text_in_paragraphs(
1291 root: ET.Element,
1292 *,
1293 paragraph_tag: str,
1294 text_tag: str,
1295 set_text: Any,
1296 find: str,
1297 replacement: str,
1298 limit: int | None,
1299 ) -> int:
1300 count = 0
1301 for paragraph in root.iter(paragraph_tag):
1302 texts = list(paragraph.iter(text_tag))
1303 if not texts:
1304 continue
1305 current = "".join(node.text or "" for node in texts)
1306 if find not in current:
1307 continue
1308 remaining = None if limit is None else max(limit - count, 0)
1309 if remaining == 0:
1310 break
1311 updated, replaced = _replace_limited(current, find, replacement, remaining)
1312 if replaced:
1313 set_text(paragraph, updated)
1314 count += replaced
1315 return count
1316
1317
1318 def _replace_limited(value: str, find: str, replacement: str, limit: int | None) -> tuple[str, int]:
1319 if limit is None:
1320 return value.replace(find, replacement), value.count(find)
1321 return value.replace(find, replacement, limit), min(value.count(find), limit)
1322
1323
1324 def _set_word_paragraph_text(paragraph: ET.Element, text: str) -> None:
1325 keep = [child for child in list(paragraph) if child.tag == qn(W_NS, "pPr")]
1326 for child in list(paragraph):
1327 paragraph.remove(child)
1328 for child in keep:
1329 paragraph.append(child)
1330 paragraph.append(_word_run(text))
1331
1332
1333 def _word_paragraph(text: str) -> ET.Element:
1334 paragraph = ET.Element(qn(W_NS, "p"))
1335 paragraph.append(_word_run(text))
1336 return paragraph
1337
1338
1339 def _word_run(text: str) -> ET.Element:
1340 run = ET.Element(qn(W_NS, "r"))
1341 text_node = ET.SubElement(run, qn(W_NS, "t"))
1342 if text.startswith(" ") or text.endswith(" "):
1343 text_node.set(qn(XML_NS, "space"), "preserve")
1344 text_node.text = text
1345 return run
1346
1347
1348 def _set_drawing_paragraph_text(paragraph: ET.Element, text: str) -> None:
1349 keep = [child for child in list(paragraph) if child.tag == qn(A_NS, "pPr")]
1350 for child in list(paragraph):
1351 paragraph.remove(child)
1352 for child in keep:
1353 paragraph.append(child)
1354 run = ET.SubElement(paragraph, qn(A_NS, "r"))
1355 text_node = ET.SubElement(run, qn(A_NS, "t"))
1356 text_node.text = text
1357
1358
1359 def _require_openpyxl() -> Any:
1360 try:
1361 import openpyxl
1362 except ImportError as exc:
1363 raise RuntimeError("openpyxl is required for spreadsheet edits") from exc
1364 return openpyxl
1365
1366
1367 def _worksheet(workbook: Any, sheet: str = "") -> Any:
1368 if sheet:
1369 if sheet not in workbook.sheetnames:
1370 return workbook.create_sheet(sheet)
1371 return workbook[sheet]
1372 return workbook.active
1373
1374
1375 def _clear_worksheet(worksheet: Any) -> None:
1376 if worksheet.max_row:
1377 worksheet.delete_rows(1, worksheet.max_row)
1378
1379
1380 def _write_rows(worksheet: Any, rows: list[list[Any]], start_row: int) -> None:
1381 for row_offset, row in enumerate(rows):
1382 for col_offset, value in enumerate(row):
1383 worksheet.cell(row=start_row + row_offset, column=1 + col_offset, value=_cell_value(value))
1384
1385
1386 def _normalize_rows(value: Any) -> list[list[Any]]:
1387 if value is None:
1388 return []
1389 if isinstance(value, list):
1390 rows = value
1391 elif isinstance(value, str):
1392 rows = _rows_from_text(value)
1393 else:
1394 rows = [[value]]
1395 normalized = []
1396 for row in rows:
1397 if isinstance(row, (list, tuple)):
1398 normalized.append([_cell_value(value) for value in row])
1399 else:
1400 normalized.append([_cell_value(row)])
1401 return normalized
1402
1403
1404 def _normalize_cells(cells: Any, default_sheet: str) -> list[tuple[str, str, Any]]:
1405 if isinstance(cells, str):
1406 parsed = json.loads(cells)
1407 else:
1408 parsed = cells
1409 if not parsed:
1410 raise ValueError("cells is required for set_cells")
1411
1412 result: list[tuple[str, str, Any]] = []
1413 if isinstance(parsed, dict):
1414 for ref, value in parsed.items():
1415 sheet, cell = _split_cell_ref(str(ref), default_sheet)
1416 result.append((sheet, cell, _cell_value(value)))
1417 elif isinstance(parsed, list):
1418 for item in parsed:
1419 if not isinstance(item, dict):
1420 raise ValueError("cells list entries must be objects")
1421 ref = str(item.get("cell") or item.get("ref") or "")
1422 sheet = str(item.get("sheet") or default_sheet)
1423 if "!" in ref:
1424 sheet, ref = _split_cell_ref(ref, default_sheet)
1425 if not ref:
1426 raise ValueError("cell is required for each cells entry")
1427 result.append((sheet, ref, _cell_value(item.get("value"))))
1428 else:
1429 raise ValueError("cells must be an object or list")
1430 return result
1431
1432
1433 def _split_cell_ref(ref: str, default_sheet: str) -> tuple[str, str]:
1434 if "!" not in ref:
1435 return default_sheet, ref
1436 sheet, cell = ref.split("!", 1)
1437 return sheet.strip("'") or default_sheet, cell
1438
1439
1440 def _cell_value(value: Any) -> Any:
1441 if isinstance(value, (dict, list)):
1442 return json.dumps(value, ensure_ascii=False)
1443 return value
1444
1445
1446 def _rows_from_text(content: str) -> list[list[str]]:
1447 text = str(content or "").strip("\n")
1448 if not text.strip():
1449 return []
1450 lines = [line for line in text.splitlines() if line.strip()]
1451 markdown_rows = _markdown_table_rows(lines)
1452 if markdown_rows:
1453 return markdown_rows
1454
1455 delimiter = "\t" if any("\t" in line for line in lines) else ("," if any("," in line for line in lines) else None)
1456 if delimiter:
1457 return [row for row in csv.reader(io.StringIO("\n".join(lines)), delimiter=delimiter)]
1458 return [[line] for line in lines]
1459
1460
1461 def _markdown_table_rows(lines: list[str]) -> list[list[str]]:
1462 table_lines = [line.strip() for line in lines if line.strip().startswith("|") and line.strip().endswith("|")]
1463 if len(table_lines) < 2:
1464 return []
1465 rows = []
1466 for line in table_lines:
1467 cells = [cell.strip() for cell in line.strip("|").split("|")]
1468 if all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells):
1469 continue
1470 rows.append(cells)
1471 return rows
1472
1473
1474 def _zip_member(data: bytes, name: str) -> bytes:
1475 with zipfile.ZipFile(io.BytesIO(data)) as archive:
1476 return archive.read(name)
1477
1478
1479 def _odf_content_root(path: Path) -> ET.Element:
1480 with zipfile.ZipFile(path) as archive:
1481 return ET.fromstring(archive.read("content.xml"))
1482
1483
1484 def _odf_text_lines(root: ET.Element) -> list[str]:
1485 lines = []
1486 for node in root.iter():
1487 if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}:
1488 continue
1489 text = "".join(node.itertext()).strip()
1490 if text:
1491 lines.append(text)
1492 return lines
1493
1494
1495 def _ods_sheets_from_bytes(
1496 data: bytes,
1497 *,
1498 max_rows: int | None = None,
1499 max_cols: int | None = None,
1500 strict_limits: bool = False,
1501 ) -> list[dict[str, Any]]:
1502 root = ET.fromstring(_zip_member(data, "content.xml"))
1503 sheets = []
1504 for index, table in enumerate(root.iter(qn(ODF_TABLE_NS, "table")), start=1):
1505 name = table.get(qn(ODF_TABLE_NS, "name")) or table.get("name") or f"Sheet{index}"
1506 rows = []
1507 for row in table:
1508 if row.tag != qn(ODF_TABLE_NS, "table-row"):
1509 continue
1510 values = _ods_row_values(row, max_cols=max_cols, strict_limits=strict_limits)
1511 repeat_rows = _repeat_count(row.get(qn(ODF_TABLE_NS, "number-rows-repeated")))
1512 append_count = repeat_rows
1513 if max_rows is not None:
1514 remaining = max(max_rows - len(rows), 0)
1515 append_count = min(repeat_rows, remaining)
1516 if strict_limits and repeat_rows > append_count and _row_has_content(values):
1517 raise ValueError(f"ODS direct editing is limited to {max_rows} populated rows per sheet.")
1518 if remaining == 0:
1519 if not strict_limits:
1520 break
1521 continue
1522 for _ in range(append_count):
1523 rows.append(values.copy())
1524 if max_rows is not None and len(rows) >= max_rows and not strict_limits:
1525 break
1526 sheets.append({"name": name, "rows": _trim_blank_edges(rows)})
1527 return sheets
1528
1529
1530 def _ods_row_values(
1531 row: ET.Element,
1532 *,
1533 max_cols: int | None = None,
1534 strict_limits: bool = False,
1535 ) -> list[Any]:
1536 values = []
1537 for cell in row:
1538 if cell.tag not in {qn(ODF_TABLE_NS, "table-cell"), qn(ODF_TABLE_NS, "covered-table-cell")}:
1539 continue
1540 value = _ods_cell_value(cell)
1541 repeat = _repeat_count(cell.get(qn(ODF_TABLE_NS, "number-columns-repeated")))
1542 append_count = repeat
1543 if max_cols is not None:
1544 remaining = max(max_cols - len(values), 0)
1545 append_count = min(repeat, remaining)
1546 if strict_limits and repeat > append_count and _cell_has_content(value):
1547 raise ValueError(f"ODS direct editing is limited to {max_cols} populated columns per sheet.")
1548 if remaining == 0:
1549 if not strict_limits:
1550 break
1551 continue
1552 for _ in range(append_count):
1553 values.append(value)
1554 if max_cols is not None and len(values) >= max_cols and not strict_limits:
1555 break
1556 return values
1557
1558
1559 def _ods_cell_value(cell: ET.Element) -> Any:
1560 value_type = str(cell.get(qn(ODF_OFFICE_NS, "value-type")) or "").lower()
1561 if value_type in {"float", "currency", "percentage"}:
1562 raw = cell.get(qn(ODF_OFFICE_NS, "value"))
1563 if raw not in (None, ""):
1564 try:
1565 number = float(raw)
1566 return int(number) if number.is_integer() else number
1567 except ValueError:
1568 pass
1569 if value_type == "boolean":
1570 raw = str(cell.get(qn(ODF_OFFICE_NS, "boolean-value")) or "").lower()
1571 if raw in {"true", "false"}:
1572 return raw == "true"
1573 text = "\n".join("".join(node.itertext()).strip() for node in cell.iter(qn(ODF_TEXT_NS, "p")))
1574 return text.strip()
1575
1576
1577 def _repeat_count(value: Any) -> int:
1578 try:
1579 count = int(value or 1)
1580 except (TypeError, ValueError):
1581 count = 1
1582 return max(1, count)
1583
1584
1585 def _trim_blank_edges(rows: list[list[Any]]) -> list[list[Any]]:
1586 trimmed = []
1587 for row in rows:
1588 next_row = list(row)
1589 while next_row and not _cell_has_content(next_row[-1]):
1590 next_row.pop()
1591 trimmed.append(next_row)
1592 while trimmed and not _row_has_content(trimmed[-1]):
1593 trimmed.pop()
1594 return trimmed
1595
1596
1597 def _cell_has_content(value: Any) -> bool:
1598 if value is None:
1599 return False
1600 if isinstance(value, str):
1601 return bool(value.strip())
1602 return True
1603
1604
1605 def _row_has_content(row: list[Any]) -> bool:
1606 return any(_cell_has_content(value) for value in row)
1607
1608
1609 def _ods_sheet(sheets: list[dict[str, Any]], name: str = "") -> dict[str, Any]:
1610 normalized = str(name or "").strip()
1611 if normalized:
1612 for sheet in sheets:
1613 if str(sheet["name"]).casefold() == normalized.casefold():
1614 return sheet
1615 sheet = {"name": normalized, "rows": []}
1616 sheets.append(sheet)
1617 return sheet
1618 return sheets[0]
1619
1620
1621 def _cell_indices(cell: str) -> tuple[int, int]:
1622 match = re.fullmatch(r"\$?([A-Za-z]{1,4})\$?([1-9][0-9]*)", str(cell or "").strip())
1623 if not match:
1624 raise ValueError(f"Invalid cell reference: {cell}")
1625 col = 0
1626 for char in match.group(1).upper():
1627 col = col * 26 + (ord(char) - 64)
1628 return int(match.group(2)), col
1629
1630
1631 def _set_matrix_value(rows: list[list[Any]], row_idx: int, col_idx: int, value: Any) -> None:
1632 while len(rows) < row_idx:
1633 rows.append([])
1634 row = rows[row_idx - 1]
1635 while len(row) < col_idx:
1636 row.append("")
1637 row[col_idx - 1] = value
1638
1639
1640 def _odp_text_slides(data: bytes) -> list[dict[str, Any]]:
1641 root = ET.fromstring(_zip_member(data, "content.xml"))
1642 slides = []
1643 for page in root.iter(qn(ODF_DRAW_NS, "page")):
1644 lines = []
1645 for node in page.iter():
1646 if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}:
1647 continue
1648 text = "".join(node.itertext()).strip()
1649 if text:
1650 lines.append(text)
1651 if lines:
1652 slides.append({"title": lines[0], "bullets": lines[1:]})
1653 return slides
1654
1655
1656 def _pptx_text_slides(data: bytes) -> list[dict[str, Any]]:
1657 slides = []
1658 with zipfile.ZipFile(io.BytesIO(data)) as archive:
1659 for name in _slide_names(archive):
1660 root = ET.fromstring(archive.read(name))
1661 lines = []
1662 for paragraph in root.iter(qn(A_NS, "p")):
1663 text = "".join(node.text or "" for node in paragraph.iter(qn(A_NS, "t"))).strip()
1664 if text:
1665 lines.append(text)
1666 if lines:
1667 slides.append({"title": lines[0], "bullets": lines[1:]})
1668 return slides
1669
1670
1671 def _normalize_slides(value: Any) -> list[dict[str, Any]]:
1672 if value is None:
1673 return []
1674 if isinstance(value, str):
1675 stripped = value.strip()
1676 if not stripped:
1677 return []
1678 if stripped.startswith("[") or stripped.startswith("{"):
1679 return _normalize_slides(json.loads(stripped))
1680 chunks = re.split(r"(?m)^\s*---+\s*$", stripped)
1681 result = []
1682 for chunk in chunks:
1683 lines = [line.strip(" -\t") for line in chunk.splitlines() if line.strip()]
1684 if not lines:
1685 continue
1686 result.append({"title": lines[0], "bullets": lines[1:]})
1687 return result
1688 if isinstance(value, dict):
1689 return [_slide_from_mapping(value)]
1690 if isinstance(value, list):
1691 result = []
1692 for item in value:
1693 if isinstance(item, dict):
1694 result.append(_slide_from_mapping(item))
1695 elif isinstance(item, str):
1696 result.extend(_normalize_slides(item))
1697 elif isinstance(item, (list, tuple)):
1698 lines = [str(part) for part in item if str(part).strip()]
1699 if lines:
1700 result.append({"title": lines[0], "bullets": lines[1:]})
1701 else:
1702 result.append({"title": str(item), "bullets": []})
1703 return result
1704 return [{"title": str(value), "bullets": []}]
1705
1706
1707 def _slide_from_mapping(value: dict[str, Any]) -> dict[str, Any]:
1708 title = str(value.get("title") or value.get("heading") or "Slide")
1709 bullets = value.get("bullets")
1710 if bullets is None:
1711 body = value.get("body") or value.get("content") or ""
1712 bullets = [line.strip(" -\t") for line in str(body).splitlines() if line.strip()]
1713 elif isinstance(bullets, str):
1714 bullets = [line.strip(" -\t") for line in bullets.splitlines() if line.strip()]
1715 else:
1716 bullets = [str(item) for item in bullets]
1717 return {"title": title, "bullets": bullets}
1718
1719
1720 def _pptx_from_slides(slides: list[dict[str, Any]]) -> bytes:
1721 return pptx_writer.pptx_from_slides(slides)
1722
1723
1724 def _pptx_content_types(count: int) -> str:
1725 overrides = [
1726 '<Override PartName="/ppt/presentation.xml" '
1727 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
1728 ]
1729 for index in range(1, count + 1):
1730 overrides.append(
1731 f'<Override PartName="/ppt/slides/slide{index}.xml" '
1732 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
1733 )
1734 return (
1735 '<?xml version="1.0" encoding="UTF-8"?>'
1736 f'<Types xmlns="{CT_NS}">'
1737 '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
1738 '<Default Extension="xml" ContentType="application/xml"/>'
1739 + "".join(overrides)
1740 + "</Types>"
1741 )
1742
1743
1744 def _pptx_presentation_rels(count: int) -> str:
1745 rels = []
1746 for index in range(1, count + 1):
1747 rels.append(
1748 f'<Relationship Id="rId{index}" '
1749 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
1750 f'Target="slides/slide{index}.xml"/>'
1751 )
1752 return '<?xml version="1.0" encoding="UTF-8"?>' + f'<Relationships xmlns="{REL_NS}">' + "".join(rels) + "</Relationships>"
1753
1754
1755 def _pptx_presentation_xml(count: int) -> str:
1756 slide_ids = "".join(f'<p:sldId id="{255 + index}" r:id="rId{index}"/>' for index in range(1, count + 1))
1757 return (
1758 '<?xml version="1.0" encoding="UTF-8"?>'
1759 f'<p:presentation xmlns:p="{P_NS}" xmlns:r="{R_NS}">'
1760 f"<p:sldIdLst>{slide_ids}</p:sldIdLst>"
1761 '<p:sldSz cx="9144000" cy="5143500"/>'
1762 "</p:presentation>"
1763 )
1764
1765
1766 def _pptx_slide_xml(slide: dict[str, Any]) -> str:
1767 title = str(slide.get("title") or "Slide")
1768 bullets = [str(item) for item in slide.get("bullets") or []]
1769 paragraphs = [title, *bullets]
1770 text = "".join(f"<a:p><a:r><a:t>{escape(item)}</a:t></a:r></a:p>" for item in paragraphs)
1771 return (
1772 '<?xml version="1.0" encoding="UTF-8"?>'
1773 f'<p:sld xmlns:a="{A_NS}" xmlns:p="{P_NS}">'
1774 "<p:cSld><p:spTree>"
1775 '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
1776 "<p:grpSpPr/>"
1777 '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Content"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>'
1778 f"<p:txBody><a:bodyPr/><a:lstStyle/>{text}</p:txBody>"
1779 "</p:sp>"
1780 "</p:spTree></p:cSld>"
1781 "</p:sld>"
1782 )
1783
1784
1785 def _slide_names(archive: zipfile.ZipFile) -> list[str]:
1786 return sorted(
1787 [name for name in archive.namelist() if name.startswith("ppt/slides/slide") and name.endswith(".xml")],
1788 key=_natural_key,
1789 )
1790
1791
1792 def _natural_key(value: str) -> list[Any]:
1793 return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)]
1794
1795
1796 def _text_lines(content: str) -> list[str]:
1797 lines = [line.rstrip() for line in str(content or "").splitlines()]
1798 return lines or [""]
1799
1800
1801 def _int_or_none(value: Any) -> int | None:
1802 if value in (None, ""):
1803 return None
1804 try:
1805 number = int(value)
1806 except (TypeError, ValueError):
1807 return None
1808 return number if number > 0 else None
1809
1810
1811 def _xml_bytes(root: ET.Element) -> bytes:
1812 return ET.tostring(root, encoding="utf-8", xml_declaration=True)
1813
1814
1815 def _zip_from_existing(files: dict[str, bytes]) -> bytes:
1816 buffer = io.BytesIO()
1817 with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
1818 for name, data in files.items():
1819 archive.writestr(name, data)
1820 return buffer.getvalue()
1821
1822
1823 def _zip_map(files: dict[str, str | bytes]) -> bytes:
1824 buffer = io.BytesIO()
1825 with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
1826 for name, value in files.items():
1827 archive.writestr(name, value.encode("utf-8") if isinstance(value, str) else value)
1828 return buffer.getvalue()
1829
1830
1831 def _trim_payload(payload: dict[str, Any], max_chars: int) -> dict[str, Any]:
1832 text = json.dumps(payload, ensure_ascii=False, default=str)
1833 if len(text) <= max_chars:
1834 return payload
1835 trimmed = dict(payload)
1836 if "paragraphs" in trimmed:
1837 trimmed["paragraphs"] = trimmed["paragraphs"][:20]
1838 if "sheets" in trimmed:
1839 trimmed["sheets"] = [
1840 {**sheet, "preview_rows": sheet.get("preview_rows", [])[:20]}
1841 for sheet in trimmed["sheets"][:4]
1842 ]
1843 if "slides" in trimmed:
1844 trimmed["slides"] = trimmed["slides"][:12]
1845 if "text" in trimmed and isinstance(trimmed["text"], str):
1846 trimmed["text"] = trimmed["text"][:max_chars] + "\n... [truncated]"
1847 trimmed["truncated"] = True
1848 return trimmed