diff --git a/document_generator.py b/document_generator.py new file mode 100644 index 0000000..b3a93c1 --- /dev/null +++ b/document_generator.py @@ -0,0 +1,1278 @@ +""" +title: Document Generator 3.9 +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_open_webui_version: 0.4.0 +funding_url: https://github.com/newnol +requirements: python-docx, openpyxl, python-pptx, aiohttp, pillow +version: 3.9 +licence: MIT +""" + +import os +import io +import re +import csv +import json +import socket +import base64 +import asyncio +import logging +import ipaddress +import tempfile +from urllib.parse import urlparse + +import aiohttp +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +# Опциональные зависимости: при отсутствии пакета отдаём понятную ошибку, а не NameError +try: + from docx import Document + from docx.shared import Pt, Inches as DocxInches + from docx.oxml import OxmlElement + from docx.oxml.ns import qn as docx_qn + from docx.enum.text import WD_ALIGN_PARAGRAPH + + _HAS_DOCX = True +except ImportError: + Document = None + _HAS_DOCX = False + +try: + from openpyxl import Workbook + from openpyxl.styles import Font + from openpyxl.utils import get_column_letter + + _HAS_OPENPYXL = True +except ImportError: + Workbook = None + _HAS_OPENPYXL = False + +try: + from pptx import Presentation + from pptx.util import Inches + from pptx.oxml.ns import qn as pptx_qn + + _HAS_PPTX = True +except ImportError: + Presentation = None + Inches = None + _HAS_PPTX = False + +try: + from PIL import Image + + _HAS_PIL = True +except ImportError: + Image = None + _HAS_PIL = False + + +MIME_DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +MIME_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +MIME_PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation" + +# Разрешённый формат внутренних ссылок на файлы Open WebUI (без query и без "..") +_OWUI_FILE_RE = re.compile(r"/api/v1/files/[\w-]+/content") + + +# ====================================================================== +# Вся внутренняя логика живёт здесь, а НЕ в Tools: Open WebUI строит +# спецификацию инструментов по методам класса Tools, и хелперы с "_" +# (_upload_file, _safe_unlink, _fetch_image ...) не должны быть доступны модели. +# ====================================================================== +class _Impl: + def __init__(self, valves): + self.valves = valves + + # ------------------------------------------------------------------ + # Утилиты + # ------------------------------------------------------------------ + _ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") + + @staticmethod + def _safe_filename(filename: str, ext: str) -> str: + """Только basename, Unicode-буквы разрешены, расширение регистронезависимо.""" + name = os.path.basename(str(filename or "").strip().replace("\\", "/")) + name = re.sub(r"[^\w.\- ]", "_", name).replace(" ", "_") + name = re.sub(r"_+", "_", name).strip("._ ") + if name.lower().endswith(ext): + name = name[: -len(ext)] + name = name.strip("._")[:100] or "file" + return name + ext + + @staticmethod + def _safe_unlink(path: str): + try: + os.remove(path) + except OSError: + pass + + @staticmethod + async def _emit(emitter, text: str, done: bool = False): + if not emitter: + return + try: + await emitter( + {"type": "status", "data": {"description": text, "done": done}} + ) + except Exception: + pass + + def _get_api_key(self, __user__) -> str: + try: + valves = (__user__ or {}).get("valves") + if not valves: + return "" + if hasattr(valves, "openwebui_api_key"): + return valves.openwebui_api_key or "" + if isinstance(valves, dict): + return valves.get("openwebui_api_key", "") or "" + except Exception: + pass + return "" + + @staticmethod + def _strip_fences(s: str) -> str: + """Убирает ```json ... ``` вокруг данных, которые любят добавлять LLM.""" + s = str(s).strip() + if s.startswith("```"): + m = re.match(r"^```[\w-]*[ \t]*\n?(.*?)\n?```\s*$", s, re.DOTALL) + if m: + return m.group(1).strip() + elif s[:1] not in ("[", "{"): + m = re.search(r"```[\w-]*[ \t]*\n(.*?)```", s, re.DOTALL) + if m and m.group(1).strip()[:1] in ("[", "{"): + return m.group(1).strip() + return s + + # ------------------------------------------------------------------ + # Загрузка изображений (с защитой от SSRF) + # ------------------------------------------------------------------ + async def _resolve_public_ip(self, host: str, port: int) -> str: + """Резолвит хост и возвращает ПЕРВЫЙ публичный IP; приватные адреса отклоняет. + + IP фиксируем один раз и дальше коннектимся именно к нему, чтобы избежать + DNS-rebinding (TOCTOU между проверкой и реальным запросом). + """ + loop = asyncio.get_running_loop() + infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) + for info in infos: + ip = ipaddress.ip_address(info[4][0].split("%")[0]) + if ip.is_global: + return str(ip) + raise ValueError("Адрес не публичный — запрос заблокирован") + + async def _fetch_image(self, src: str, api_key: str = "") -> bytes: + """ + Возвращает байты изображения. Поддерживает: + - base64 data URI + - относительный путь /api/v1/files//content (файлы самого Open WebUI, с токеном) + - публичный http(s) URL (приватные адреса блокируются) + """ + src = str(src).strip() + max_bytes = max(1, self.valves.max_image_mb) * 1024 * 1024 + + if src.startswith("data:"): + try: + data = base64.b64decode(src.split(",", 1)[1]) + except Exception: + raise ValueError("Некорректные base64-данные изображения") + if len(data) > max_bytes: + raise ValueError("Изображение слишком большое") + return data + + headers = {} + pub = self.valves.public_domain.rstrip("/") + if pub and src.startswith(pub + "/"): + src = src[len(pub) :] + + server_hostname = None + if src.startswith("/") and not src.startswith("//"): + # Строгая проверка: никаких "..", query и посторонних путей — + # иначе запрос с токеном пользователя может уйти на другой endpoint + if not _OWUI_FILE_RE.fullmatch(src): + raise ValueError("Разрешён только /api/v1/files//content") + url = self.valves.internal_api_url.rstrip("/") + src + headers["Authorization"] = f"Bearer {api_key}" + else: + p = urlparse(src) + if p.scheme not in ("http", "https") or not p.hostname: + raise ValueError("Недопустимый URL изображения") + port = p.port or (443 if p.scheme == "https" else 80) + pinned_ip = await self._resolve_public_ip(p.hostname, port) + host_part = ( + f"[{pinned_ip}]:{port}" if ":" in pinned_ip else f"{pinned_ip}:{port}" + ) + # Сохраняем query-строку (подписанные ссылки: ?token=..., ?X-Amz-...) + path = p.path or "/" + if p.query: + path += "?" + p.query + url = f"{p.scheme}://{host_part}{path}" + # Host с портом, если он нестандартный; userinfo отбрасываем + host_header = p.hostname + if ":" in host_header: # IPv6-литерал + host_header = f"[{host_header}]" + if p.port: + host_header += f":{p.port}" + headers["Host"] = host_header + if p.scheme == "https": + server_hostname = p.hostname # SNI, чтобы сертификат валидировался по домену + + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get( + url, + headers=headers, + allow_redirects=False, + server_hostname=server_hostname, + ) as resp: + if resp.status != 200: + raise ValueError(f"HTTP {resp.status}") + if resp.content_length and resp.content_length > max_bytes: + raise ValueError("Изображение слишком большое") + buf = bytearray() + async for chunk in resp.content.iter_chunked(65536): + buf += chunk + if len(buf) > max_bytes: + raise ValueError("Изображение слишком большое") + return bytes(buf) + + # ------------------------------------------------------------------ + # EXCEL: разбор данных + # ------------------------------------------------------------------ + _DATA_KEYS = ("data", "rows", "cells") + _WRAP_KEYS = ("sheets", "tables", "data", "result", "content", "rows") + _NAME_KEYS = ("name", "title", "sheet", "sheet_name") + + def _find_tables(self, data, name="Sheet1", depth=0): + """ + Рекурсивно ищет таблицы. Возвращает [(имя_листа, [[строка], ...]), ...]. + Понимает: [[..]], [{..}], {"sheets":[{"name","data"}]}, {"S1":[[..]], "S2":[[..]]}, + {"data":[[..]]}, плоский {"a":1,"b":2}. + """ + if depth > 6: + return [] + + if isinstance(data, list): + if not data: + return [] + + # Список «листов»: [{"name": "..", "data": [[..]]}, ...] + def is_sheet(x): + return isinstance(x, dict) and any( + isinstance(x.get(k), list) for k in self._DATA_KEYS + ) + + if all(is_sheet(x) for x in data): + out = [] + for i, x in enumerate(data, 1): + nm = next( + (str(x[k]) for k in self._NAME_KEYS if x.get(k)), f"Sheet{i}" + ) + dk = next(k for k in self._DATA_KEYS if isinstance(x.get(k), list)) + out.extend(self._find_tables(x[dk], nm, depth + 1)) + return out + + first = data[0] + if isinstance(first, (list, tuple)): + rows = [r if isinstance(r, (list, tuple)) else [r] for r in data] + return [(name, rows)] + if isinstance(first, dict): + headers = [] + for item in data: + if isinstance(item, dict): + for k in item: + if k not in headers: + headers.append(k) + rows = [headers] + for item in data: + if isinstance(item, dict): + rows.append([item.get(k, "") for k in headers]) + else: + rows.append([item]) + return [(name, rows)] + # список скаляров -> одна строка + return [(name, [list(data)])] + + if isinstance(data, dict): + for key in self._WRAP_KEYS: + if key in data: + res = self._find_tables(data[key], name, depth + 1) + if res: + return res + + values = list(data.values()) + # {"Лист1": [[..]], "Лист2": [[..]]} -> несколько листов + if values and all(isinstance(v, list) and v for v in values): + out = [] + for k, v in data.items(): + out.extend(self._find_tables(v, str(k), depth + 1)) + if out: + return out + + # плоский объект -> заголовки + одна строка + if values and all(not isinstance(v, (list, dict)) for v in values): + return [(name, [list(data.keys()), values])] + + for v in values: + if isinstance(v, (list, dict)): + res = self._find_tables(v, name, depth + 1) + if res: + return res + return [] + + @staticmethod + def _parse_plain_table(text: str) -> list: + """Фолбэк, если пришёл не JSON: Markdown-таблица или CSV/TSV.""" + lines = [l for l in str(text).strip().splitlines() if l.strip()] + if not lines: + return [] + if all(l.strip().startswith("|") for l in lines): + rows = [] + for l in lines: + cells = [c.strip() for c in l.strip().strip("|").split("|")] + if all(re.fullmatch(r":?-+:?", c) for c in cells if c) and any(cells): + continue + rows.append(cells) + return rows + delim = max(",;\t", key=lambda d: lines[0].count(d)) + if lines[0].count(delim) == 0: + return [] + return [row for row in csv.reader(lines, delimiter=delim)] + + @staticmethod + def _coerce_number(s: str): + t = s.strip() + m = re.fullmatch(r"-?(0|[1-9]\d*)([.,]\d+)?", t) + if m: + return float(t.replace(",", ".")) if m.group(2) else int(t) + if re.fullmatch(r"-?[1-9]\d{0,2}([ \u00a0]\d{3})+([.,]\d+)?", t): + t = re.sub(r"[ \u00a0]", "", t).replace(",", ".") + return float(t) if "." in t else int(t) + return s + + def _clean_cell(self, v): + if v is None or isinstance(v, bool): + return v + if isinstance(v, int): + return v + if isinstance(v, float): + return v if v == v and v not in (float("inf"), float("-inf")) else str(v) + if isinstance(v, (dict, list)): + v = json.dumps(v, ensure_ascii=False) + s = self._ILLEGAL.sub("", str(v)) + if self.valves.convert_numeric_strings: + n = self._coerce_number(s) + if not isinstance(n, str): + return n + return s[:32767] + + @staticmethod + def _sheet_title(name, used: set) -> str: + n = re.sub(r"[\[\]:*?/\\]", "_", str(name)).strip("' ")[:31] or "Sheet" + base, i = n, 2 + while n.lower() in used: + suffix = f"_{i}" + n = base[: 31 - len(suffix)] + suffix + i += 1 + used.add(n.lower()) + return n + + def _write_sheet(self, ws, rows) -> bool: + """Пишет лист. Возвращает True, если строки были обрезаны по max_rows.""" + limit = max(1, self.valves.max_rows) + truncated = len(rows) > limit + rows = rows[:limit] + widths = {} + for r_idx, row in enumerate(rows, 1): + if not isinstance(row, (list, tuple)): + row = [row] + for c_idx, val in enumerate(row, 1): + v = self._clean_cell(val) + cell = ws.cell(row=r_idx, column=c_idx, value=v) + if ( + isinstance(v, str) + and v.startswith("=") + and not self.valves.allow_formulas + ): + cell.data_type = "s" + if v is not None: + longest = max((len(x) for x in str(v).split("\n")), default=0) + widths[c_idx] = max(widths.get(c_idx, 0), longest) + for c_idx, w in widths.items(): + ws.column_dimensions[get_column_letter(c_idx)].width = min( + max(w + 2, 8), 60 + ) + + # Шапка: если первая строка целиком из непустых строк + if rows: + first = rows[0] if isinstance(rows[0], (list, tuple)) else [rows[0]] + if first and all(isinstance(x, str) and x.strip() for x in first): + for cell in ws[1]: + cell.font = Font(bold=True) + if len(rows) > 1: + ws.freeze_panes = "A2" + ws.auto_filter.ref = ws.dimensions + return truncated + + def _build_xlsx(self, tables, path) -> list: + """Собирает xlsx. Возвращает имена листов, обрезанных по max_rows.""" + wb = Workbook() + wb.remove(wb.active) + used = set() + truncated = [] + for name, rows in tables: + title = self._sheet_title(name, used) + ws = wb.create_sheet(title) + if self._write_sheet(ws, rows): + truncated.append(title) + wb.save(path) + return truncated + + # ------------------------------------------------------------------ + # WORD: Markdown -> docx + # ------------------------------------------------------------------ + _INLINE_RE = re.compile( + r"(\*\*.+?\*\*|`[^`]+`|\*[^*\s][^*]*?\*|\[[^\]]+\]\([^)]+\))" + ) + + def _add_inline(self, p, text: str): + for part in self._INLINE_RE.split(text): + if not part: + continue + if part.startswith("**") and part.endswith("**") and len(part) > 4: + p.add_run(part[2:-2]).bold = True + elif part.startswith("`") and part.endswith("`") and len(part) > 2: + 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:-1]).italic = True + elif part.startswith("[") and "](" in part: + # Было: r"\[(.+?)\]\((.+)?)\)$" — лишняя ')' -> re.error на любой ссылке + m = re.match(r"\[(.+?)\]\((.+?)\)$", part) + p.add_run(f"{m.group(1)} ({m.group(2)})" if m else part) + else: + p.add_run(part) + + def _add_table(self, doc, block): + rows = [] + for ln in block: + cells = [c.strip() for c in ln.strip().strip("|").split("|")] + if any(cells) and all(re.fullmatch(r":?-+:?", c) for c in cells if c): + continue # строка-разделитель |---|---| + rows.append(cells) + if not rows: + return + n = max(len(r) for r in rows) + table = doc.add_table(rows=len(rows), cols=n) + table.style = "Table Grid" + for ri, r in enumerate(rows): + for ci in range(n): + cell = table.cell(ri, ci) + p = cell.paragraphs[0] + self._add_inline(p, r[ci] if ci < len(r) else "") + if ri == 0: + for run in p.runs: + run.bold = True + doc.add_paragraph() + + def _add_markdown_content_to_docx(self, doc, content: str): + lines = content.replace("\r\n", "\n").split("\n") + i = 0 + while i < len(lines): + raw = lines[i].expandtabs(4).rstrip() + line = raw.strip() + + if not line: + i += 1 + continue + + if line.startswith("```"): # блок кода + code = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith("```"): + code.append(lines[i]) + i += 1 + i += 1 + p = doc.add_paragraph() + run = p.add_run("\n".join(code)) + run.font.name = "Consolas" + run.font.size = Pt(9) + continue + + if line.lower() in ("", "\\pagebreak"): + doc.add_page_break() + i += 1 + continue + + if re.fullmatch(r"[-*_]{3,}", line): # горизонтальная линия — пропускаем + i += 1 + continue + + if line.startswith("|"): # таблица + block = [] + while i < len(lines) and lines[i].strip().startswith("|"): + block.append(lines[i].strip()) + i += 1 + self._add_table(doc, block) + continue + + m = re.match(r"^(#{1,6})\s+(.*)$", line) + if m: + doc.add_heading( + m.group(2).replace("**", "").strip(), level=len(m.group(1)) + ) + i += 1 + continue + + m = re.match(r"^(\s*)[-*+•]\s+(.*)$", raw) + if m: + lvl = min(len(m.group(1)) // 2, 2) + style = "List Bullet" if lvl == 0 else f"List Bullet {lvl + 1}" + p = doc.add_paragraph(style=style) + self._add_inline(p, m.group(2)) + i += 1 + continue + + m = re.match(r"^(\s*)(\d+)[.)]\s+(.*)$", raw) + if m: + # Нумерация пишется текстом, чтобы разные списки не «склеивались» + lvl = min(len(m.group(1)) // 2, 2) + p = doc.add_paragraph() + p.paragraph_format.left_indent = DocxInches(0.25 * (lvl + 1)) + p.paragraph_format.first_line_indent = DocxInches(-0.25) + self._add_inline(p, f"{m.group(2)}. {m.group(3)}") + i += 1 + continue + + p = doc.add_paragraph() + self._add_inline(p, line) + i += 1 + + def _add_page_number(self, doc): + p = doc.sections[0].footer.paragraphs[0] + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + + def fld(kind): + r = p.add_run() + el = OxmlElement("w:fldChar") + el.set(docx_qn("w:fldCharType"), kind) + r._r.append(el) + + fld("begin") + r = p.add_run() + it = OxmlElement("w:instrText") + it.set(docx_qn("xml:space"), "preserve") + it.text = "PAGE" + r._r.append(it) + fld("end") + + def _build_docx(self, title: str, content: str, path: str): + doc = Document() + try: + st = doc.styles["Normal"] + if self.valves.docx_font_name: + st.font.name = self.valves.docx_font_name + rpr = st.element.get_or_add_rPr() + rfonts = rpr.find(docx_qn("w:rFonts")) + if rfonts is not None: + rfonts.set(docx_qn("w:eastAsia"), self.valves.docx_font_name) + if self.valves.docx_font_size: + st.font.size = Pt(self.valves.docx_font_size) + except Exception: + logger.warning("Не удалось применить шрифт Word") + doc.add_heading(title, 0) + self._add_markdown_content_to_docx(doc, content) + if self.valves.docx_page_numbers: + self._add_page_number(doc) + doc.save(path) + + # ------------------------------------------------------------------ + # PPTX + # ------------------------------------------------------------------ + _SLIDE_HDR = re.compile( + r"^\s*(?:(?:slide|слайд)\s*\d+\s*[:.\-–—)]*\s*(.*)|#{1,2}\s+(.*))$", + re.IGNORECASE, + ) + _MD_IMG = re.compile(r"!\[[^\]]*\]\((\S+?)\)") + _DATA_URI_LINE = re.compile( + r"^\s*(data:image/[a-z+.\-]+;base64,[A-Za-z0-9+/=]+)\s*$", re.MULTILINE + ) + _URL_LINE = re.compile( + r"^\s*((?:https?://\S+\.(?:png|jpe?g|webp|gif|bmp)(?:\?\S*)?)|(?:/api/v1/files/[\w-]+/content))\s*$", + re.IGNORECASE | re.MULTILINE, + ) + + def _parse_slides_from_text(self, text: str) -> list: + """ + Слайды из простого текста: + "Slide 1: Title" / "Слайд 1: Заголовок" / "# Заголовок" / "## Заголовок", + далее — пункты. Подзаголовки ### остаются внутри слайда. + """ + slides, cur = [], None + for line in str(text).replace("\r\n", "\n").split("\n"): + m = self._SLIDE_HDR.match(line) + if m: + title = m.group(1) if m.group(1) is not None else (m.group(2) or "") + title = title.strip().strip(":").strip().replace("**", "") + cur = {"title": title, "content": []} + slides.append(cur) + elif cur is not None: + cur["content"].append(line) + + if not slides: # заголовков нет — делим по пустым строкам + for block in re.split(r"\n\s*\n", str(text).strip()): + ls = [l for l in block.split("\n") if l.strip()] + if ls: + slides.append( + { + "title": ls[0].strip().strip(":").replace("**", ""), + "content": ls[1:], + } + ) + + return [ + {"title": s["title"], "content": "\n".join(s["content"]).strip()} + for s in slides + if s["title"] or any(x.strip() for x in s["content"]) + ] + + @classmethod + def _flatten_list(cls, items, depth=0): + out = [] + for it in items: + if isinstance(it, (list, tuple)): + out.extend(cls._flatten_list(it, depth + 1)) + elif isinstance(it, dict): + out.append(" " * depth + json.dumps(it, ensure_ascii=False)) + else: + out.append(" " * depth + str(it)) + return out + + def _split_image(self, item: dict): + """Возвращает (источник_картинки, текст_без_картинки).""" + src = str(item.get("image") or "").strip() + content = str(item.get("content") or "") + + m = self._MD_IMG.search(content) + if m: + src = src or m.group(1) + content = self._MD_IMG.sub("", content) + for pat in (self._DATA_URI_LINE, self._URL_LINE): + m = pat.search(content) + if m: + src = src or m.group(1) + content = pat.sub("", content) + + lines = [l for l in content.split("\n") if l.strip() not in ("", "-", "*", "•")] + return src, "\n".join(lines).strip() + + def _normalize_slides(self, raw: list) -> list: + out = [] + for s in raw: + if isinstance(s, str): + s = {"title": s} + if not isinstance(s, dict): + continue + content = s.get("content", s.get("bullets", s.get("text", ""))) + if isinstance(content, (list, tuple)): + content = "\n".join(self._flatten_list(content)) + src, text = self._split_image({"image": s.get("image"), "content": content}) + out.append( + { + "title": self._ILLEGAL.sub("", str(s.get("title", "")).strip()), + "text": self._ILLEGAL.sub("", text), + "image": src, + "notes": self._ILLEGAL.sub("", str(s.get("notes") or "").strip()), + } + ) + return out + + def _split_long_slides(self, slides: list) -> list: + limit = self.valves.max_bullets_per_slide + if not limit or limit < 1: + return slides + out = [] + for s in slides: + lines = [l for l in s["text"].split("\n") if l.strip()] + if s["image"] or len(lines) <= limit: + out.append(s) + continue + for n, start in enumerate(range(0, len(lines), limit), 1): + part = dict(s) + part["text"] = "\n".join(lines[start : start + limit]) + if n > 1: + part["title"] = f"{s['title']} ({n})" + part["notes"] = "" + out.append(part) + return out + + @staticmethod + def _make_widescreen(prs): + """4:3 -> 16:9 с пересчётом координат заголовков/плейсхолдеров шаблона.""" + old = prs.slide_width + if abs(old - Inches(10)) > 10000: + return + new = int(Inches(13.333)) + k = new / old + prs.slide_width = new + + def scale(shapes, only_explicit): + for sh in shapes: + try: + if ( + only_explicit + and sh._element.spPr.find(pptx_qn("a:xfrm")) is None + ): + continue + if sh.left is None: + continue + sh.left = int(sh.left * k) + sh.width = int(sh.width * k) + except Exception: + pass + + scale(prs.slide_master.shapes, False) + for lay in prs.slide_layouts: + scale(lay.shapes, True) + + @staticmethod + def _drop_empty_placeholders(slide): + 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): + fallback = None + for ph in slide.placeholders: + if ph.placeholder_format.idx == 1: + return ph + if ( + ph.placeholder_format.idx != 0 + and ph.has_text_frame + and fallback is None + ): + fallback = ph + return fallback + + @staticmethod + def _fill_bullets(ph, text: str): + tf = ph.text_frame + lines = [l for l in text.replace("\r\n", "\n").split("\n") if l.strip()] + for i, line in enumerate(lines): + line = line.expandtabs(4) + indent = len(line) - len(line.lstrip()) + t = re.sub(r"^\s*(?:[-*•+]|\d+[.)])\s+", "", line) + t = re.sub(r"^#+\s*", "", t).replace("**", "").strip() + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + p.text = t + p.level = min(indent // 2, 4) + + @staticmethod + def _normalize_image(data: bytes): + """Возвращает (bytes, w, h); форматы, которые pptx не читает (WEBP/TIFF и т.п.), конвертирует в PNG.""" + if not _HAS_PIL: + raise ValueError("Pillow не установлен: `pip install pillow`") + with Image.open(io.BytesIO(data)) as im: + fmt, (w, h) = im.format, im.size + if fmt in ("PNG", "JPEG", "GIF", "BMP"): + return data, w, h + buf = io.BytesIO() + im.convert("RGBA").save(buf, "PNG") + return buf.getvalue(), w, h + + @staticmethod + def _place_picture(slide, data, w, h, left, top, box_w, box_h): + k = min(box_w / w, box_h / h) + pw, ph = max(1, int(w * k)), max(1, int(h * k)) + slide.shapes.add_picture( + io.BytesIO(data), + int(left + (box_w - pw) / 2), + int(top + (box_h - ph) / 2), + pw, + ph, + ) + + def _build_pptx(self, title: str, slides: list, images: dict, path: str): + tpl = self.valves.pptx_template_path.strip() + use_tpl = bool(tpl) and os.path.isfile(tpl) + prs = Presentation(tpl) if use_tpl else Presentation() + if not use_tpl and self.valves.pptx_widescreen: + self._make_widescreen(prs) + + def layout(idx): + n = len(prs.slide_layouts) + return prs.slide_layouts[idx if idx < n else min(1, n - 1)] + + # Титульный слайд + s = prs.slides.add_slide(layout(0)) + if s.shapes.title is not None: + s.shapes.title.text = title + self._drop_empty_placeholders(s) + + margin = Inches(0.5) + box_left, box_top = margin, Inches(1.5) + box_w = prs.slide_width - 2 * margin + box_h = prs.slide_height - Inches(2) + + for idx, it in enumerate(slides): + has_body = bool(it["text"] or it["image"]) + slide = prs.slides.add_slide(layout(1 if has_body else 2)) + if slide.shapes.title is not None: + slide.shapes.title.text = it["title"] + body = self._body_placeholder(slide) + text = it["text"] + + if it["image"]: + img = images.get(idx) + if isinstance(img, tuple): + data, w, h = img + if text and body is not None: # текст слева, картинка справа + half = int(box_w / 2) + body.left, body.top = box_left, box_top + body.width, body.height = half - Inches(0.1), box_h + self._fill_bullets(body, text) + self._place_picture( + slide, + data, + w, + h, + box_left + half + Inches(0.1), + box_top, + half - Inches(0.1), + box_h, + ) + else: + self._place_picture( + slide, data, w, h, box_left, box_top, box_w, box_h + ) + text = None + else: + reason = img if isinstance(img, str) else "неизвестная ошибка" + text = ( + text + "\n" if text else "" + ) + f"(изображение не загружено: {reason})" + + if text and body is not None: + self._fill_bullets(body, text) + + if it["notes"]: + slide.notes_slide.notes_text_frame.text = it["notes"] + self._drop_empty_placeholders(slide) + + prs.save(path) + + # ------------------------------------------------------------------ + # Загрузка в Open WebUI + # ------------------------------------------------------------------ + def _public_base(self, request) -> str: + base = self.valves.public_domain.rstrip("/") + if "your-openwebui-domain" in base and request is not None: + try: + base = str(request.base_url).rstrip("/") + except Exception: + pass + return base + + async def _upload_file( + self, + local_path: str, + filename: str, + api_key: str, + mime: str, + request=None, + note: str = "", + emitter=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) + if attempt < 2: # после последней попытки не ждём + 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]}" + + # Прикрепляем файл к сообщению: скачивание из чата идёт с сессией пользователя, + # без отдельного входа в браузере (прямая ссылка требует авторизации) + if emitter: + try: + await emitter( + { + "type": "files", + "data": { + "files": [ + { + "type": "file", + "id": file_id, + "name": filename, + "url": f"/api/v1/files/{file_id}/content", + "size": len(file_bytes), + } + ] + }, + } + ) + except Exception: + logger.warning("Не удалось прикрепить файл к сообщению", exc_info=True) + + download_url = f"{self._public_base(request)}/api/v1/files/{file_id}/content" + warn = f"\n⚠️ {note}" if note else "" + return f""" + +--- +### ✅ Документ создан +📁 **Файл:** `{filename}` +🔗 [Скачать]({download_url}){warn} +--- +""" + + def _check_input_size(self, s, limit=None) -> str: + limit = self.valves.max_content_chars if limit is None else limit + if len(str(s)) > limit: + return f"❌ Слишком большой ввод (лимит {limit} символов)." + return "" + + # ------------------------------------------------------------------ + # Реализации публичных функций + # ------------------------------------------------------------------ + async def word(self, title, content, user, emitter, request) -> str: + 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`. Установите: `pip install 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(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(emitter, "Загружаю файл…") + res = await self._upload_file( + local_path, filename, api_key, MIME_DOCX, request, emitter=emitter + ) + await self._emit(emitter, "Готово", done=True) + return res + + async def excel(self, filename, data_json, user, emitter, request) -> str: + api_key = self._get_api_key(user) + if not api_key: + return "⚠️ Не указан API Key (UserValves -> openwebui_api_key)." + if not _HAS_OPENPYXL: + return "⚠️ Не установлен пакет `openpyxl`. Установите: `pip install openpyxl`." + err = self._check_input_size(data_json, self.valves.max_excel_chars) + 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): + raw = self._parse_plain_table(text) # 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(emitter, "Генерирую Excel…") + try: + truncated = 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}" + + note = "" + if truncated: + note = ( + f"Строки обрезаны до {self.valves.max_rows} на листах: " + + ", ".join(truncated) + ) + + await self._emit(emitter, "Загружаю файл…") + res = await self._upload_file( + local_path, filename, api_key, MIME_XLSX, request, note, emitter=emitter + ) + await self._emit(emitter, "Готово", done=True) + return res + + async def pptx(self, filename, title, slides_content, user, emitter, request) -> str: + 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`. Установите: `pip install 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(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(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(emitter, "Загружаю файл…") + res = await self._upload_file( + local_path, filename, api_key, MIME_PPTX, request, emitter=emitter + ) + await self._emit(emitter, "Готово", done=True) + return res + + +# ====================================================================== +# Tools: только Valves, UserValves и три публичные функции (их и видит модель) +# ====================================================================== +class Tools: + class Valves(BaseModel): + public_domain: str = Field( + default="https://your-openwebui-domain.com", + description="Публичный домен Open WebUI, БЕЗ слэша в конце. Если не задан — берётся из запроса.", + ) + internal_api_url: str = Field( + default="http://127.0.0.1:8080", + description="Внутренний URL API (Docker по умолчанию: http://127.0.0.1:8080)", + ) + process_uploaded_files: bool = Field( + default=False, + description="Прогонять сгенерированный файл через RAG-обработку при загрузке (обычно не нужно)", + ) + max_rows: int = Field( + default=50000, description="Excel: максимум строк на лист" + ) + max_slides: int = Field(default=60, description="PPTX: максимум слайдов") + max_content_chars: int = Field( + default=200000, description="Максимальная длина входного текста/JSON" + ) + max_excel_chars: int = Field( + default=2000000, + description="Excel: отдельный лимит входного JSON (больше max_content_chars, т.к. крупные таблицы дают длинный JSON)", + ) + max_bullets_per_slide: int = Field( + default=8, + description="PPTX: если в слайде больше пунктов — он делится на несколько (0 = не делить)", + ) + max_image_mb: int = Field( + default=10, description="PPTX: максимум МБ на картинку" + ) + allow_formulas: bool = Field( + default=False, + description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection)", + ) + convert_numeric_strings: bool = Field( + default=False, + description="Excel: превращать строки вида '123' и '1 234,5' в числа", + ) + docx_font_name: str = Field(default="Calibri", description="Word: шрифт") + docx_font_size: int = Field(default=11, description="Word: размер шрифта, pt") + docx_page_numbers: bool = Field( + default=True, description="Word: номера страниц" + ) + pptx_widescreen: bool = Field( + default=True, description="PPTX: формат 16:9 (для встроенного шаблона)" + ) + pptx_template_path: str = Field( + default="", + description="PPTX: путь к своему .pptx-шаблону на сервере (необязательно)", + ) + + class UserValves(BaseModel): + openwebui_api_key: str = Field( + default="", + description="🔴 ОБЯЗАТЕЛЬНО: API Key (Settings -> Account -> API Keys)", + json_schema_extra={"input": {"type": "password"}}, + ) + + def __init__(self): + self.valves = self.Valves() + self.user_valves = self.UserValves() + # file_handler намеренно не выставляется: он отключает стандартную обработку вложений + + 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 + """ + # Impl создаётся на каждый вызов: self.valves может быть заменён из UI + return await _Impl(self.valves).word( + title, content, __user__, __event_emitter__, __request__ + ) + + 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. + """ + return await _Impl(self.valves).excel( + filename, data_json, __user__, __event_emitter__, __request__ + ) + + 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. + """ + return await _Impl(self.valves).pptx( + filename, title, slides_content, __user__, __event_emitter__, __request__ + ) \ No newline at end of file