Vibe coding :P

This commit is contained in:
2025-08-20 21:59:11 +02:00
parent 7ef1502249
commit 28685780f3
2 changed files with 344 additions and 183 deletions
+195 -57
View File
@@ -1,22 +1,47 @@
# latex_commands.py # latex_commands.py
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import io import io
from typing import Optional, List
import logging import logging
from typing import Optional
import discord import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
from constants import ( from constants import (
LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, LATEX_MAX_ATTACH_MB, LATEX_MAX_ZIP_MB, ALLOWED_ROLES,
OPENAI_MODEL, ALLOWED_ROLES, GUILD_ID GUILD_ID,
LATEX_MAX_ATTACH_MB,
LATEX_MAX_COMPILE_SECONDS,
LATEX_MAX_ZIP_MB,
LATEX_TEX_ENGINE,
OPENAI_MODEL,
) )
from latex_functions import ( from latex_functions import (
is_tex_attachment, compile_single_tex_bytes, compile_zip_to_zip, ask_openai_diagnosis compile_single_tex_bytes,
compile_zip_to_zip,
is_safe_asset_name,
is_safe_tex_name,
) )
async def _collect_attachments_bytes(attachments, max_mb: int):
"""
Zwraca słownik {filename: bytes} dla wszystkich załączników, do limitu MB.
Nie podbija wyjątków z .read() — zostawiamy to wyżej; tu zwracamy, co się udało.
"""
out = {}
for a in attachments or []:
# limit rozmiaru
if (getattr(a, "size", 0) or 0) > max_mb * 1024 * 1024:
continue
data = await a.read()
out[a.filename] = data
return out
class LatexModule(commands.Cog): class LatexModule(commands.Cog):
def __init__(self, bot: commands.Bot, logger_name: str): def __init__(self, bot: commands.Bot, logger_name: str):
self.bot = bot self.bot = bot
@@ -31,47 +56,94 @@ class LatexModule(commands.Cog):
guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID), guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID),
) )
@commands.has_any_role(*ALLOWED_ROLES) @commands.has_any_role(*ALLOWED_ROLES)
async def tex(self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True): async def tex(
self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True
):
self.logger.info("LaTeX command invoked by %s", ctx.author) self.logger.info("LaTeX command invoked by %s", ctx.author)
ch = ctx.message.channel ch = ctx.message.channel
# ZBIERZ WSZYSTKIE ZAŁĄCZNIKI
async with ch.typing(): async with ch.typing():
atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)] all_bytes = await _collect_attachments_bytes(
if not atts: ctx.message.attachments, LATEX_MAX_ATTACH_MB
return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False) )
self.logger.debug("LaTeX attachments: %s", ", ".join(a.filename for a in atts)) if not all_bytes:
files: List[discord.File] = [] return await ctx.reply(
parts: List[str] = [] f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
for att in atts: )
# PODZIEL NA GŁÓWNE .TEX i WSPIERAJĄCE
tex_names = [
n
for n in all_bytes.keys()
if n.lower().endswith(".tex") and is_safe_tex_name(n)
]
if not tex_names:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
support_names = [
n
for n in all_bytes.keys()
if n not in tex_names and is_safe_asset_name(n)
]
files_to_send = []
parts = []
for tex_name in tex_names:
try: try:
data = await att.read() main_bytes = all_bytes[tex_name]
self.logger.info("LaTeX read %s bytes from %s", len(data), att.filename) support = [
res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name) (n, all_bytes[n])
self.logger.info("LaTeX result for %s: %s", att.filename, res) for n in support_names
self.logger.info("LaTeX log (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:]) if n != tex_name and is_safe_asset_name(n)
]
# (ważne) do supportów dopuszczamy również **inne .tex** dla \input/\include
other_tex = [
(n, all_bytes[n])
for n in tex_names
if n != tex_name and is_safe_tex_name(n)
]
support.extend(other_tex)
res = await compile_single_tex_bytes(
main_bytes,
tex_name,
LATEX_TEX_ENGINE,
LATEX_MAX_COMPILE_SECONDS,
logger="discord",
support_files=support,
)
# Twoja dotychczasowa obsługa success/fail przykładowo:
if res["ok"] and res["pdf_bytes"]: if res["ok"] and res["pdf_bytes"]:
b = io.BytesIO(res["pdf_bytes"]) b = io.BytesIO(res["pdf_bytes"])
b.seek(0) b.seek(0)
b.name = res["pdf_name"] b.name = res["pdf_name"]
files.append(discord.File(b, filename=res["pdf_name"])) files_to_send.append(discord.File(b, filename=res["pdf_name"]))
parts.append(f"✅ `{att.filename}` → `{res['pdf_name']}`") parts.append(f"✅ `{tex_name}` → `{res['pdf_name']}`")
else: else:
self.logger.info("LaTeX compile failed for %s", att.filename) parts.append(
excerpt = "" f"❌ `{tex_name}` — błąd kompilacji. (log: { (res['log_text'] or '')[-400] })"
if include_source_excerpt: )
try: # Tutaj wywołujesz swoją ścieżkę do OpenAI diagnozy, którą ogarnęliśmy wcześniej.
excerpt = data.decode("utf-8", errors="ignore")[:4000]
except Exception: except Exception:
excerpt = "" # let it crash — pełny stack w logu i re-raise (Discord.py to pokaże sensownie)
advice = await ask_openai_diagnosis(res["log_text"] or "", excerpt, OPENAI_MODEL, logger=self.logger_name) logging.getLogger("discord").exception(
parts.append(f"❌ `{att.filename}` — błąd kompilacji.\nDiagnoza:\n{advice}") "Unhandled exception while compiling %s", tex_name
raise )
except Exception as e:
self.logger.info("Exception %s: %s", att.filename, e)
parts.append(f"⚠️ `{att.filename}` — wyjątek (szczegóły w logu).")
raise raise
content = f"**LaTeX** — {len(atts)} plik(ów). Model: `{OPENAI_MODEL}`\n\n" + "\n\n".join(parts) content = (
await ctx.reply(content if len(content)<1900 else content[:1900]+"", files=files, mention_author=False) f"**LaTeX** — {len(tex_names)} plik(ów). Model: `{OPENAI_MODEL}`\n\n"
+ "\n\n".join(parts)
)
await ctx.reply(
content if len(content) < 1900 else content[:1900] + "",
files=parts,
mention_author=False,
)
# -------- /latexclean (PDF only; log -> debug logger) -------- # -------- /latexclean (PDF only; log -> debug logger) --------
@commands.hybrid_command( @commands.hybrid_command(
@@ -84,57 +156,123 @@ class LatexModule(commands.Cog):
async def latexclean(self, ctx: commands.Context): async def latexclean(self, ctx: commands.Context):
ch = ctx.message.channel ch = ctx.message.channel
async with ch.typing(): async with ch.typing():
atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)] all_bytes = await _collect_attachments_bytes(
if not atts: ctx.message.attachments, LATEX_MAX_ATTACH_MB
return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False) )
if not all_bytes:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
files: List[discord.File] = [] # PODZIEL NA GŁÓWNE .TEX i WSPIERAJĄCE
parts: List[str] = [] tex_names = [
for att in atts: n
for n in all_bytes.keys()
if n.lower().endswith(".tex") and is_safe_tex_name(n)
]
if not tex_names:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
support_names = [
n
for n in all_bytes.keys()
if n not in tex_names and is_safe_asset_name(n)
]
files_to_send = []
parts = []
for tex_name in tex_names:
try: try:
data = await att.read() main_bytes = all_bytes[tex_name]
res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name) support = [
self.logger.debug("LaTeX CLEAN (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:]) (n, all_bytes[n])
for n in support_names
if n != tex_name and is_safe_asset_name(n)
]
# (ważne) do supportów dopuszczamy również **inne .tex** dla \input/\include
other_tex = [
(n, all_bytes[n])
for n in tex_names
if n != tex_name and is_safe_tex_name(n)
]
support.extend(other_tex)
res = await compile_single_tex_bytes(
main_bytes,
tex_name,
LATEX_TEX_ENGINE,
LATEX_MAX_COMPILE_SECONDS,
logger="discord",
support_files=support,
)
# Twoja dotychczasowa obsługa success/fail przykładowo:
if res["ok"] and res["pdf_bytes"]: if res["ok"] and res["pdf_bytes"]:
b = io.BytesIO(res["pdf_bytes"]) b = io.BytesIO(res["pdf_bytes"])
b.seek(0) b.seek(0)
b.name = res["pdf_name"] b.name = res["pdf_name"]
files.append(discord.File(b, filename=res["pdf_name"])) files_to_send.append(discord.File(b, filename=res["pdf_name"]))
parts.append(f"✅ `{att.filename}`") parts.append(f"✅ `{tex_name}` → `{res['pdf_name']}`")
else: else:
parts.append(f"❌ `{att.filename}` — błąd (log w debug).") parts.append(
except Exception as e: f"❌ `{tex_name}` — błąd kompilacji. (log: { (res['log_text'] or '')[-400] })"
self.logger.debug("CLEAN exception %s: %s", att.filename, e) )
parts.append(f"⚠️ `{att.filename}` — wyjątek (log w debug).") # Tutaj wywołujesz swoją ścieżkę do OpenAI diagnozy, którą ogarnęliśmy wcześniej.
except Exception:
# let it crash — pełny stack w logu i re-raise (Discord.py to pokaże sensownie)
logging.getLogger("discord").exception(
"Unhandled exception while compiling %s", tex_name
)
raise raise
await ctx.reply(
await ctx.reply("**LaTeX clean** — " + ", ".join(parts), files=files, mention_author=False) "**LaTeX clean** — " + ", ".join(parts),
files=tex_names,
mention_author=False,
)
# -------- /texbatch (ZIP -> ZIP) -------- # -------- /texbatch (ZIP -> ZIP) --------
@app_commands.command( @app_commands.command(
name="texbatch", name="texbatch",
description="Wyślij ZIP z .tex → odeślę ZIP z PDF-ami (tylko udane)." description="Wyślij ZIP z .tex → odeślę ZIP z PDF-ami (tylko udane).",
) )
@app_commands.describe(archive=f"Archiwum .zip (max ~{LATEX_MAX_ZIP_MB} MiB)") @app_commands.describe(archive=f"Archiwum .zip (max ~{LATEX_MAX_ZIP_MB} MiB)")
@app_commands.checks.has_any_role(*ALLOWED_ROLES) @app_commands.checks.has_any_role(*ALLOWED_ROLES)
async def texbatch(self, interaction: discord.Interaction, archive: discord.Attachment): async def texbatch(
self, interaction: discord.Interaction, archive: discord.Attachment
):
await interaction.response.defer() await interaction.response.defer()
if not archive or not archive.filename.lower().endswith(".zip"): if not archive or not archive.filename.lower().endswith(".zip"):
return await interaction.followup.send("Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True) return await interaction.followup.send(
"Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True
)
if archive.size > LATEX_MAX_ZIP_MB * 1024 * 1024: if archive.size > LATEX_MAX_ZIP_MB * 1024 * 1024:
return await interaction.followup.send(f"ZIP > {LATEX_MAX_ZIP_MB} MiB — za duży.", ephemeral=True) return await interaction.followup.send(
f"ZIP > {LATEX_MAX_ZIP_MB} MiB — za duży.", ephemeral=True
)
data = await archive.read() data = await archive.read()
out_zip_bytes, failed = await compile_zip_to_zip(data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name) out_zip_bytes, failed = await compile_zip_to_zip(
data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name
)
if not out_zip_bytes and failed: if not out_zip_bytes and failed:
return await interaction.followup.send("Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True) return await interaction.followup.send(
"Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True
)
bio = io.BytesIO(out_zip_bytes) bio = io.BytesIO(out_zip_bytes)
bio.seek(0) bio.seek(0)
bio.name = "compiled_pdfs.zip" bio.name = "compiled_pdfs.zip"
if failed: if failed:
self.logger.debug("BATCH failed: %s", ", ".join(failed)) self.logger.debug("BATCH failed: %s", ", ".join(failed))
await interaction.followup.send("✅ Gotowe (ZIP w załączniku)." + (f" ❌ Błędy: {len(failed)}" if failed else ""), files=[discord.File(bio, filename=bio.name)]) await interaction.followup.send(
"✅ Gotowe (ZIP w załączniku)."
+ (f" ❌ Błędy: {len(failed)}" if failed else ""),
files=[discord.File(bio, filename=bio.name)],
)
async def setup(bot): async def setup(bot):
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
+145 -122
View File
@@ -1,153 +1,176 @@
# latex_functions.py
# -*- coding: utf-8 -*-
from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re import re
import shutil
import tempfile import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, Iterable, List, Optional, Tuple
from ai_functions import handle_response
import shutil
from typing import Iterable, Tuple
# ===== Bezpieczeństwo nazw/rozszerzeń =====
SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE)
SAFE_ASSET_NAME = re.compile( SAFE_ASSET_NAME = re.compile(
r"^[\w\-. /]+?\.(tex|png|jpg|jpeg|pdf|svg|eps|bmp|gif|sty|cls|bib|bst|bbx|cbx|def|cfg)$", r"^[\w\-. /]+?\.(tex|png|jpg|jpeg|pdf|svg|eps|bmp|gif|sty|cls|bib|bst|bbx|cbx|def|cfg)$",
re.IGNORECASE, re.IGNORECASE,
) )
def is_safe_tex_name(name: str) -> bool:
return bool(SAFE_TEX_NAME.match(name or ""))
def is_safe_asset_name(name: str) -> bool: def is_safe_asset_name(name: str) -> bool:
return bool(SAFE_ASSET_NAME.match(name or "")) return bool(SAFE_ASSET_NAME.match(name or ""))
# ===== Uruchamianie kompilatora =====
def is_tex_attachment(att, max_mb: int) -> bool: async def _run(cmd: List[str], cwd: Path, timeout_sec: int, logger_name: Optional[str]) -> Tuple[int, str]:
"""Filtruje discord.Attachment: tylko .tex + limit MB + nazwa bezpieczna.""" """Uruchamia proces kompilatora w cwd, zwraca (rc, sklejone_stdout_stderr)."""
return bool( logger = logging.getLogger(logger_name) if logger_name else logging.getLogger()
getattr(att, "filename", "") logger.debug("RUN %s (cwd=%s, timeout=%s)", " ".join(cmd), cwd, timeout_sec)
and is_safe_tex_name(att.filename)
and getattr(att, "size", 0) <= max_mb * 1024 * 1024
)
def _build_cmd(engine: str, tex_filename: str) -> list[str]:
base = (engine or "").strip().lower()
if base in ("pdflatex", "xelatex", "lualatex"):
return [
base,
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
"-no-shell-escape",
tex_filename,
]
if base == "latexmk":
return [
"latexmk",
"-pdf",
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
tex_filename,
]
if base == "tectonic":
return [
"tectonic",
"-X",
"compile",
"--keep-logs",
"--outdir",
".",
tex_filename,
]
return [
"pdflatex",
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
"-no-shell-escape",
tex_filename,
]
async def run_latex_two_passes(
tex_filename: str,
workdir: Path,
tex_engine: str,
timeout_sec: int,
logger: str = None,
):
logger = logging.getLogger(logger) if logger else None
async def run_once(cmd: list[str]) -> tuple[int, str]:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
cwd=str(workdir),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
cwd=str(cwd),
) )
try: try:
out_b, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec) out = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
return (proc.returncode or 0), (out_b or b"").decode(
"utf-8", errors="replace"
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
try: try:
proc.kill() proc.kill()
except Exception: finally:
raise pass
return 124, f"[Timeout] Compilation exceeded {timeout_sec}s.\n" return 124, "TIMEOUT: compiler took too long"
rc = proc.returncode
log = (out[0] or b"").decode("utf-8", errors="ignore")
logger.debug("RC=%s, LOG TAIL:\n%s", rc, log[-1500:])
return rc, log
logs = [] def _build_cmd(tex_engine: str, main_tex: str) -> List[str]:
cmd = _build_cmd(tex_engine, tex_filename) """Buduje komendę kompilatora; domyślnie tectonic/pdflatex/xelatex."""
if logger: if tex_engine.lower() == "tectonic":
logger.debug("LaTeX cmd: %s (cwd=%s)", " ".join(cmd), workdir) # Tectonic sam robi wielofazowość, ale i tak uruchamiamy 2x (bezpiecznie dla bib/refs)
return ["tectonic", "--keep-intermediates", "--synctex", main_tex]
rc1, out1 = await run_once(cmd) elif tex_engine.lower() in ("pdflatex", "xelatex", "lualatex"):
logs.append(out1) return [tex_engine, "-interaction=nonstopmode", "-halt-on-error", main_tex]
second_pass = not ((tex_engine or "").strip().lower() == "tectonic")
if rc1 != 0:
log_text = "".join(logs)
pdf = workdir / (Path(tex_filename).stem + ".pdf")
return False, log_text, pdf
if second_pass:
rc2, out2 = await run_once(cmd)
logs.append(out2)
ok = rc2 == 0
else: else:
ok = rc1 == 0 # fallback traktujemy jak pdflatex
return ["pdflatex", "-interaction=nonstopmode", "-halt-on-error", main_tex]
log_text = "".join(logs) async def run_latex_two_passes(
pdf = workdir / (Path(tex_filename).stem + ".pdf") tex_filename: str, workdir: Path, tex_engine: str, timeout_sec: int, logger: Optional[str] = None
ok = ok and pdf.exists() ) -> Tuple[bool, str, Path]:
return ok, log_text, pdf """
Uruchamia kompilację 2 razy (bezpieczeństwo referencji/bib).
Zwraca: (ok, log_text, pdf_path).
"""
logger_obj = logging.getLogger(logger) if logger else logging.getLogger()
logger_obj.info("Compiling %s with %s engine", tex_filename, tex_engine)
logs: List[str] = []
pdf_path = workdir / (Path(tex_filename).stem + ".pdf")
async def ask_openai_diagnosis( # PASS 1
log_text: str, tex_excerpt: str, model: str, logger: str = None rc1, log1 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
) -> str: logs.append(log1)
logger = logging.getLogger(logger) if logger else None if rc1 != 0:
sys = ( return False, "".join(logs), pdf_path
"You are a LaTeX build diagnostician. Analyze pdflatex log and return:\n"
"1) Root causes (bullets)\n2) Minimal working fix (code block)\n3) Alternative fix"
)
logger.debug("LaTeX DIAG (%s)\n%s", model, log_text[-9000:])
usr = f"---LOG---\n{log_text[-9000:]}\n---END LOG---"
if tex_excerpt:
usr += f"\n---TEX EXCERPT---\n{tex_excerpt[:4000]}\n---END EXCERPT---"
prompt = [{"role": sys, "content": "none"}, {"role": "user", "content": usr}]
response, _ = await handle_response( # PASS 2 (często już nic nie robi, ale nie szkodzi)
prompt, rc2, log2 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
None, logs.append(log2)
None, ok = (rc2 == 0) and pdf_path.exists()
prompt, return ok, "".join(logs), pdf_path
"latex_diagnosis",
"NONE", # ===== Kompilacje: pojedynczy TEX + ZIP =====
algorithm=model,
none_request=prompt, async def compile_single_tex_bytes(
) tex_bytes: bytes,
return response filename: str,
tex_engine: str,
timeout_sec: int,
logger: Optional[str] = None,
support_files: Optional[Iterable[Tuple[str, bytes]]] = None,
) -> Dict[str, Optional[object]]:
"""
Kompiluje pojedynczy .tex + dowolne pliki pomocnicze (obrazy, sty/cls/bib, inne .tex).
Każdy plik zapisujemy w katalogu roboczym widocznym dla kompilatora.
"""
log = logging.getLogger(logger) if logger else logging.getLogger()
log.info("Compiling %s with %s engine", filename, tex_engine)
if not is_safe_tex_name(filename):
return {"ok": False, "pdf_bytes": None, "log_text": "Invalid .tex filename", "pdf_name": None}
with tempfile.TemporaryDirectory(prefix="latex_one_") as td:
wd = Path(td)
(wd / Path(filename).name).write_bytes(tex_bytes)
# Zapisz wszystkie supporty obok głównego pliku
if support_files:
for fname, data in support_files:
if not fname or not is_safe_asset_name(fname):
continue
# spłaszczamy do nazwy bazowej (bez podkatalogów z zewn. źródeł)
(wd / Path(fname).name).write_bytes(data)
ok, log_text, pdf_path = await run_latex_two_passes(Path(filename).name, wd, tex_engine, timeout_sec, logger)
if ok and pdf_path.exists():
return {
"ok": True,
"pdf_bytes": pdf_path.read_bytes(),
"log_text": log_text,
"pdf_name": pdf_path.name,
}
return {"ok": False, "pdf_bytes": None, "log_text": log_text, "pdf_name": pdf_path.name}
async def compile_zip_to_zip(
zip_bytes: bytes, tex_engine: str, timeout_sec: int, logger: Optional[str] = None
) -> Tuple[bytes, List[str]]:
"""
Rozpakowuje ZIP, znajduję wszystkie .tex i dla KAŻDEGO tworzy osobny katalog roboczy
ze skopiowanym CAŁYM drzewem, by zachować ścieżki względne obrazków/plików.
Zwraca (zip_pdf_bytes, lista_rel_sciezek_które_padły).
"""
log = logging.getLogger(logger) if logger else logging.getLogger()
failed: List[str] = []
out_pdf_paths: List[Path] = []
with tempfile.TemporaryDirectory(prefix="latex_zip_") as td:
td_path = Path(td)
src_root = td_path / "src"
src_root.mkdir(parents=True, exist_ok=True)
# 1) Rozpakuj
zpath = td_path / "in.zip"
zpath.write_bytes(zip_bytes)
with zipfile.ZipFile(zpath, "r") as zf:
zf.extractall(src_root)
# 2) Zbierz .tex
tex_files = [p for p in src_root.rglob("*.tex") if is_safe_tex_name(p.name)]
if not tex_files:
return b"", ["No .tex files found in archive."]
# 3) Kompiluj każdy .tex w izolacji (kopiujemy całe drzewo do osobnego workdiru)
for tex_path in tex_files:
rel_tex = tex_path.relative_to(src_root).as_posix()
work = td_path / f"build_{tex_path.stem}"
try:
shutil.copytree(src_root, work / "src")
ok, log_text, pdf = await run_latex_two_passes(rel_tex, work / "src", tex_engine, timeout_sec, logger)
if ok and pdf.exists():
out_pdf_paths.append(pdf)
else:
failed.append(rel_tex)
log.info("LaTeX FAIL %s\n%s", rel_tex, (log_text or "")[-2000:])
except Exception:
# let it crash log + re-raise (to zobaczy wyższa warstwa/discord.py)
log.exception("Exception while compiling %s", rel_tex)
raise
# 4) Spakuj wyjściowe PDF-y
out_zip = td_path / "compiled_pdfs.zip"
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for pdf in out_pdf_paths:
zf.write(pdf, arcname=pdf.name)
return out_zip.read_bytes(), failed