Обновить document_generator.py

This commit is contained in:
host committed 2026-09-21 18:09:32 +03:00
1 parent f0caa8431f
commit ee64de8210
1 file changed
+281 -6
+281 -6
View File
@@ -3,7 +3,7 @@ title: Document Generator 3.7
author: Ngo Tan Tai author: Ngo Tan Tai
author_url: https://newnol.io.vn 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. 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 funding_url: https://github.com/newnol
requirements: python-docx, openpyxl, python-pptx, aiohttp, pillow requirements: python-docx, openpyxl, python-pptx, aiohttp, pillow
version: 3.7 version: 3.7
@@ -21,6 +21,7 @@ import asyncio
import logging import logging
import ipaddress import ipaddress
import tempfile import tempfile
from urllib.parse import urlparse
import aiohttp import aiohttp
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -105,7 +106,7 @@ class Tools:
) )
allow_formulas: bool = Field( allow_formulas: bool = Field(
default=False, default=False,
description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection), description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection)",
) )
convert_numeric_strings: bool = Field( convert_numeric_strings: bool = Field(
default=False, default=False,
@@ -459,7 +460,7 @@ class Tools:
r = p.add_run(part[1:-1]) r = p.add_run(part[1:-1])
r.font.name = "Consolas" r.font.name = "Consolas"
elif part.startswith("*") and part.endswith("*") and len(part) > 2: 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: elif part.startswith("[") and "](" in part:
m = re.match(r"\[(.+?)\]\((.+)\)$", part) m = re.match(r"\[(.+?)\]\((.+)\)$", part)
p.add_run(f"{m.group(1)} ({m.group(2)})" if m else part) p.add_run(f"{m.group(1)} ({m.group(2)})" if m else part)
@@ -750,9 +751,9 @@ class Tools:
@staticmethod @staticmethod
def _drop_empty_placeholders(slide): def _drop_empty_placeholders(slide):
for ph in slide.placeholders: for sh in list(slide.placeholders):
if ph.has_text_frame and not ph.text_frame.text.strip(): if sh.has_text_frame and not sh.text_frame.text.strip():
ph._element.getparent().remove(ph._element) sh._element.getparent().remove(sh._element)
@staticmethod @staticmethod
def _body_placeholder(slide): def _body_placeholder(slide):
@@ -886,3 +887,277 @@ class Tools:
except Exception: except Exception:
pass pass
return base 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, <pagebreak> 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/<id>/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