diff --git a/document_generator.py b/document_generator.py index f62cfb6..8e43351 100644 --- a/document_generator.py +++ b/document_generator.py @@ -3,7 +3,7 @@ title: Document Generator 3.7 author: Ngo Tan Tai author_url: https://newnol.io.vn description: Generates Word, PowerPoint, and Excel files. Finds table data in complex JSON, multi-sheet Excel, Markdown tables in Word, images/notes in PPTX. -required_openwebui_version: 0.4.0 +required_open_webui_version: 0.4.0 funding_url: https://github.com/newnol requirements: python-docx, openpyxl, python-pptx, aiohttp, pillow version: 3.7 @@ -21,6 +21,7 @@ import asyncio import logging import ipaddress import tempfile +from urllib.parse import urlparse import aiohttp from pydantic import BaseModel, Field @@ -105,7 +106,7 @@ class Tools: ) allow_formulas: bool = Field( default=False, - description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection), + description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection)", ) convert_numeric_strings: bool = Field( default=False, @@ -459,7 +460,7 @@ class Tools: r = p.add_run(part[1:-1]) r.font.name = "Consolas" elif part.startswith("*") and part.endswith("*") and len(part) > 2: - p.add_run(part[1:-2]).italic = True + p.add_run(part[1:-1]).italic = True elif part.startswith("[") and "](" in part: m = re.match(r"\[(.+?)\]\((.+)\)$", part) p.add_run(f"{m.group(1)} ({m.group(2)})" if m else part) @@ -750,9 +751,9 @@ class Tools: @staticmethod def _drop_empty_placeholders(slide): - for ph in slide.placeholders: - if ph.has_text_frame and not ph.text_frame.text.strip(): - ph._element.getparent().remove(ph._element) + for sh in list(slide.placeholders): + if sh.has_text_frame and not sh.text_frame.text.strip(): + sh._element.getparent().remove(sh._element) @staticmethod def _body_placeholder(slide): @@ -886,3 +887,277 @@ class Tools: except Exception: pass return base + + async def _upload_file( + self, local_path: str, filename: str, api_key: str, mime: str, request=None + ) -> str: + url = f"{self.valves.internal_api_url.rstrip('/')}/api/v1/files/" + if not self.valves.process_uploaded_files: + url += "?process=false" + + status, text, data = 0, "", {} + try: + with open(local_path, "rb") as f: + file_bytes = f.read() + + for attempt in range(3): + try: + form = aiohttp.FormData() + form.add_field( + "file", file_bytes, filename=filename, content_type=mime + ) + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=60) + ) as session: + async with session.post( + url, + headers={"Authorization": f"Bearer {api_key}"}, + data=form, + ) as resp: + status, text = resp.status, await resp.text() + if status < 500: + break + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + status, text = 0, str(e) + await asyncio.sleep(1.5 * (attempt + 1)) + except Exception as e: + logger.exception("Upload failed for %s", filename) + return f"❌ Системная ошибка: {e}" + finally: + self._safe_unlink(local_path) + + try: + data = json.loads(text) + if not isinstance(data, dict): + data = {} + except (json.JSONDecodeError, ValueError): + data = {} + + if not 200 <= status < 300: + return f"❌ Ошибка загрузки {status}: {text[:300]}" + + file_id = data.get("id") or data.get("uuid") or data.get("file_id") + if not file_id: + return f"❌ Ошибка загрузки: в ответе нет id файла: {text[:300]}" + + download_url = f"{self._public_base(request)}/api/v1/files/{file_id}/content" + return f""" + +--- +### ✅ Документ создан +📁 **Файл:** `{filename}` +🔗 [Скачать]({download_url}) +--- +""" + + def _check_input_size(self, s) -> str: + if len(str(s)) > self.valves.max_content_chars: + return f"❌ Слишком большой ввод (лимит {self.valves.max_content_chars} символов)." + return "" + + # ------------------------------------------------------------------ + # ПУБЛИЧНЫЕ ФУНКЦИИ + # ------------------------------------------------------------------ + async def generate_word_doc( + self, + title: str, + content: str, + __user__: dict = None, + __event_emitter__=None, + __request__=None, + ) -> str: + """ + Generate a Word document (.docx). + + Args: + title: Document title (also used for the file name) + content: Document body in Markdown: headings (# .. ######), **bold**, *italic*, + `code`, bullet and numbered lists (indent 2 spaces for nesting), + tables (| a | b |), code blocks, for a page break + """ + api_key = self._get_api_key(__user__) + if not api_key: + return "⚠️ Не указан API Key (UserValves -> openwebui_api_key)." + if not _HAS_DOCX: + return "⚠️ Не установлен пакет `python-docx`." + err = self._check_input_size(content) + if err: + return err + + title = str(title).strip() or "Document" + filename = self._safe_filename(title, ".docx") + content = self._ILLEGAL.sub("", str(content)) + clean_title = self._ILLEGAL.sub("", title) + + fd, local_path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + await self._emit(__event_emitter__, "Генерирую Word-документ…") + try: + await asyncio.to_thread(self._build_docx, clean_title, content, local_path) + except Exception as e: + logger.exception("Word generation failed") + self._safe_unlink(local_path) + return f"❌ Ошибка Word: {e}" + + await self._emit(__event_emitter__, "Загружаю файл…") + res = await self._upload_file( + local_path, filename, api_key, MIME_DOCX, __request__ + ) + await self._emit(__event_emitter__, "Готово", done=True) + return res + + async def generate_excel_file( + self, + filename: str, + data_json: str, + __user__: dict = None, + __event_emitter__=None, + __request__=None, + ) -> str: + """ + Generate an Excel file (.xlsx). Finds table data automatically in the JSON. + + Args: + filename: Name of the Excel file + data_json: JSON with table data. Accepted: [[row], ...]; [{col: value}, ...]; + {"Sheet1": [[...]], "Sheet2": [[...]]} or [{"name": "S1", "data": [[...]]}, ...] + for several sheets. CSV or a Markdown table also works. + """ + api_key = self._get_api_key(__user__) + if not api_key: + return "⚠️ Не указан API Key (UserValves -> openwebui_api_key)." + if not _HAS_OPENPYXL: + return "⚠️ Не установлен пакет `openpyxl`." + err = self._check_input_size(data_json) + if err: + return err + + filename = self._safe_filename(filename, ".xlsx") + + try: + if isinstance(data_json, (list, dict)): + raw = data_json + else: + text = self._strip_fences(data_json) + try: + raw = json.loads(text) + except (json.JSONDecodeError, ValueError): + rows = self._parse_plain_table(text) + raw = rows # CSV/Markdown -> [[..]] + tables = [(n, r) for n, r in self._find_tables(raw) if r] + except Exception as e: + return f"❌ Ошибка Excel: {e}" + + if not tables: + return ( + "❌ Ошибка Excel: не удалось найти таблицу в данных. " + f"Получено: {str(data_json)[:100]}..." + ) + + fd, local_path = tempfile.mkstemp(suffix=".xlsx") + os.close(fd) + await self._emit(__event_emitter__, "Генерирую Excel…") + try: + await asyncio.to_thread(self._build_xlsx, tables, local_path) + except Exception as e: + logger.exception("Excel generation failed") + self._safe_unlink(local_path) + return f"❌ Ошибка Excel: {e}" + + await self._emit(__event_emitter__, "Загружаю файл…") + res = await self._upload_file( + local_path, filename, api_key, MIME_XLSX, __request__ + ) + await self._emit(__event_emitter__, "Готово", done=True) + return res + + async def generate_pptx_file( + self, + filename: str, + title: str, + slides_content: str, + __user__: dict = None, + __event_emitter__=None, + __request__=None, + ) -> str: + """ + Generate a PowerPoint presentation (.pptx). + + Args: + filename: Name of the PPTX file + title: Main title of the presentation + slides_content: JSON array [{"title": "...", "content": "text or list of bullets", + "image": "URL / /api/v1/files//content / data URI (optional)", + "notes": "speaker notes (optional)"}], or {"slides": [...]}, or plain text: + "Slide 1: Title\\n- bullet\\n\\nSlide 2: ...". Indent bullets with 2 spaces for sub-levels. + """ + api_key = self._get_api_key(__user__) + if not api_key: + return "⚠️ Не указан API Key (UserValves -> openwebui_api_key)." + if not _HAS_PPTX: + return "⚠️ Не установлен пакет `python-pptx`." + err = self._check_input_size(slides_content) + if err: + return err + + filename = self._safe_filename(filename, ".pptx") + title = self._ILLEGAL.sub("", str(title).strip()) or "Presentation" + + # 1. Разбор входа: JSON (в т.ч. в ```-обёртке) -> иначе текст + text = self._strip_fences(slides_content) + try: + parsed = json.loads(text) + except (json.JSONDecodeError, ValueError): + parsed = None + + if isinstance(parsed, dict): + if parsed.get("title") and not str(title).strip(): + title = str(parsed["title"]) + parsed = parsed.get("slides") or parsed.get("data") + if isinstance(parsed, list): + slides = self._normalize_slides(parsed) + else: + slides = self._normalize_slides( + self._parse_slides_from_text(str(slides_content)) + ) + + if not slides: + return ( + "❌ Ошибка PPTX: не найдено ни одного слайда. Формат:\n" + '- JSON: [{"title": "...", "content": "..."}]\n' + '- или текст: "Slide 1: Title\\n- content"' + ) + slides = self._split_long_slides(slides) + if len(slides) > self.valves.max_slides: + return f"❌ Ошибка PPTX: слишком много слайдов (лимит {self.valves.max_slides})." + + # 2. Картинки: загрузка асинхронно, до сборки файла + await self._emit(__event_emitter__, "Готовлю слайды…") + images = {} + for idx, it in enumerate(slides): + if not it["image"]: + continue + try: + raw_img = await self._fetch_image(it["image"], api_key) + images[idx] = await asyncio.to_thread(self._normalize_image, raw_img) + except Exception as e: + logger.warning("Image insert failed for slide %r: %s", it["title"], e) + images[idx] = str(e)[:120] + + # 3. Сборка + fd, local_path = tempfile.mkstemp(suffix=".pptx") + os.close(fd) + await self._emit(__event_emitter__, "Генерирую PowerPoint…") + try: + await asyncio.to_thread(self._build_pptx, title, slides, images, local_path) + except Exception as e: + logger.exception("PPTX generation failed") + self._safe_unlink(local_path) + return f"❌ Ошибка PPTX: {e}" + + await self._emit(__event_emitter__, "Загружаю файл…") + res = await self._upload_file( + local_path, filename, api_key, MIME_PPTX, __request__ + ) + await self._emit(__event_emitter__, "Готово", done=True) + return res