utilities to fix LaTeX issues

This commit is contained in:
sebastien committed 2026-09-29 10:53:32 +02:00
1 parent 2a4c5f4f7c
commit a56fc6e680
7 files changed
+164 -2

No files matched your search

+10 -1
View File
@@ -48,6 +48,15 @@ def escape_latex_underscores(text: str) -> str:
return "".join(parts)
def escape_latex_percents(text: str) -> str:
r"""Escape percent signs before annotation text is passed to LaTeX.
Unlike underscores, percent signs are comments both inside and outside
math mode. Collapsing an existing escape run keeps cleanup idempotent.
"""
return re.sub(r"\\*%", lambda _match: r"\%", text)
def normalize_overescaped_latex_commands(text: str) -> str:
r"""Collapse doubled command escapes inside LaTeX math environments.
@@ -118,7 +127,7 @@ def clean_string(text: str, lookup: dict[str, str]) -> str:
text = fast_fix(text, lookup).replace("\x00", "")
text = repair_json_escape_corruption(text)
text = normalize_overescaped_latex_commands(text)
return escape_latex_underscores(text)
return escape_latex_percents(escape_latex_underscores(text))
def clean_obj(value: Any, lookup: dict[str, str]) -> Any:
+10
View File
@@ -141,6 +141,13 @@ import subprocess
from .configuration import LATEX_AFTER, LATEX_BEFORE
LATEX_WARNING_MARKER = "[Copienator:latex-warning] "
def report_latex_warning(output_pdf_path) -> None:
"""Emit a stable, machine-readable path alongside human diagnostics."""
print(f"{LATEX_WARNING_MARKER}{Path(output_pdf_path).resolve()}")
def compile_to_pdf(text, output_pdf_path, *, strict=False):
"""Wraps text in standalone header/footer templates and compiles it to PDF."""
@@ -185,6 +192,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
shutil.move(generated_pdf, output_pdf_path)
if result.returncode != 0:
report_latex_warning(output_pdf_path)
lines = result.stdout.splitlines()
first = next((i for i, line in enumerate(lines) if line.lstrip().startswith("!")), None)
detail = "\n" + "\n".join(lines[first:first + 5]) if first is not None else ""
@@ -196,10 +204,12 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
if strict:
raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}")
elif not pdf_generated:
report_latex_warning(output_pdf_path)
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}")
if strict:
raise RuntimeError(f"No PDF generated for {output_pdf_path}")
except Exception as e:
if strict:
raise
report_latex_warning(output_pdf_path)
print(f"Compilation error for {output_pdf_path}: {e}")
+106
View File
@@ -20,6 +20,7 @@ from copienator.platform import (
open_path,
validate_windows_labels,
)
from copienator.utils import LATEX_WARNING_MARKER
from .diagnostics import collect_diagnostics
from .batch_monitor import BatchMonitor
@@ -281,6 +282,8 @@ class CopienatorApp(tk.Tk):
self.pending_refaire_commands: list[list[str]] = []
self.refaire_command_index = 0
self._correction_progress_buffer = ""
self._latex_warning_buffer = ""
self._latex_warning_paths: list[Path] = []
self.title("Copienator — assistant de correction")
self.geometry("1180x820")
@@ -493,6 +496,13 @@ class CopienatorApp(tk.Tk):
clipboard_actions.grid(row=1, column=0, columnspan=6, sticky="w", pady=(5, 0))
ttk.Button(clipboard_actions, text="Copier la sélection", command=self._copy_console_selection).pack(side="left")
ttk.Button(clipboard_actions, text="Copier toute la sortie", command=self._copy_console_all).pack(side="left", padx=6)
self.latex_warning_button = ttk.Button(
clipboard_actions,
text="Problèmes LaTeX…",
command=self._show_current_latex_warnings,
)
self.latex_warning_button.pack(side="left", padx=6)
self.latex_warning_button.pack_forget()
ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6))
self.stdin_var = tk.StringVar()
self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var)
@@ -582,6 +592,7 @@ class CopienatorApp(tk.Tk):
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
return
self.batch_monitor.stop()
self._reset_latex_warnings()
self._save_current_form()
self.state_store.load(evaluation)
for step in self.steps:
@@ -1532,6 +1543,8 @@ class CopienatorApp(tk.Tk):
last_run_values=run_values,
)
self.active_step_id = step.id
if step.id == "statement":
self._reset_latex_warnings()
if step.id == "correction":
self._correction_progress_buffer = ""
self.info_var.set("Correction : préparation des groupes…")
@@ -1642,6 +1655,7 @@ class CopienatorApp(tk.Tk):
if event == "output":
output = str(payload)
self._track_correction_progress(output)
self._track_latex_warnings(output)
self._append_console(output)
elif event == "input_echo":
self._append_console(f"> {payload}")
@@ -1670,6 +1684,88 @@ class CopienatorApp(tk.Tk):
f"({percent} %)."
)
def _reset_latex_warnings(self) -> None:
self._latex_warning_buffer = ""
self._latex_warning_paths = []
if hasattr(self, "latex_warning_button"):
self.latex_warning_button.pack_forget()
def _track_latex_warnings(self, output: str, *, flush: bool = False) -> None:
if self.active_step_id != "statement":
return
combined = self._latex_warning_buffer + output
lines = combined.splitlines(keepends=True)
self._latex_warning_buffer = ""
if lines and not lines[-1].endswith(("\n", "\r")) and not flush:
self._latex_warning_buffer = lines.pop()
for line in lines:
marker_at = line.find(LATEX_WARNING_MARKER)
if marker_at < 0:
continue
raw_path = line[marker_at + len(LATEX_WARNING_MARKER):].strip()
if not raw_path:
continue
path = Path(raw_path).expanduser()
if not path.is_absolute() and self.evaluation:
path = self.evaluation / path
path = path.resolve()
if path not in self._latex_warning_paths:
self._latex_warning_paths.append(path)
if self._latex_warning_paths:
self.latex_warning_button.pack(side="left", padx=6)
def _show_current_latex_warnings(self) -> None:
if not self._latex_warning_paths:
messagebox.showinfo("Compilation LaTeX", "Aucun problème de compilation détecté.")
return
dialog = tk.Toplevel(self)
dialog.title("Problèmes de compilation LaTeX")
dialog.geometry("900x380")
dialog.transient(self)
dialog.columnconfigure(0, weight=1)
dialog.rowconfigure(1, weight=1)
ttk.Label(
dialog,
text=(
f"{len(self._latex_warning_paths)} fichier(s) n’ont pas été compilés "
"correctement. Sélectionnez-en un pour ouvrir ses éléments."
),
padding=10,
wraplength=850,
justify="left",
).grid(row=0, column=0, sticky="ew")
paths = tk.Listbox(dialog, exportselection=False)
for pdf_path in self._latex_warning_paths:
try:
label = str(pdf_path.relative_to(self.evaluation)) if self.evaluation else str(pdf_path)
except ValueError:
label = str(pdf_path)
paths.insert("end", label)
paths.grid(row=1, column=0, sticky="nsew", padx=10)
paths.selection_set(0)
actions = ttk.Frame(dialog, padding=10)
actions.grid(row=2, column=0, sticky="ew")
def selected_pdf() -> Path:
selection = paths.curselection()
index = selection[0] if selection else 0
return self._latex_warning_paths[index]
def open_folder() -> None:
self._open_desktop_path(selected_pdf().parent, "dossier de compilation")
def open_source() -> None:
self._open_desktop_path(selected_pdf().with_suffix(".tex"), "fichier source LaTeX")
def open_pdf() -> None:
self._open_desktop_path(selected_pdf(), "fichier PDF compilé")
ttk.Button(actions, text="Ouvrir le dossier", command=open_folder).pack(side="left")
ttk.Button(actions, text="Ouvrir le .tex", command=open_source).pack(side="left", padx=6)
ttk.Button(actions, text="Ouvrir le PDF", command=open_pdf).pack(side="left")
ttk.Button(actions, text="Fermer", command=dialog.destroy).pack(side="right")
def _batch_watch_status_changed(self, message: str) -> None:
self.batch_watch_status.set(message)
self._update_controls()
@@ -1710,6 +1806,8 @@ class CopienatorApp(tk.Tk):
step_id = self.active_step_id
if not step_id:
return
if step_id == "statement":
self._track_latex_warnings("", flush=True)
status = process_status(return_code, interrupted)
if status == "success" and self.pending_refaire_commands:
command = self.pending_refaire_commands.pop(0)
@@ -1790,9 +1888,17 @@ class CopienatorApp(tk.Tk):
title = self.step_by_id[step_id].title
self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n")
self.info_var.set(f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}).")
if step_id == "statement" and self._latex_warning_paths:
count = len(self._latex_warning_paths)
self.info_var.set(
f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}) — "
f"{count} problème(s) LaTeX."
)
self.active_step_id = None
self._populate_tree()
self._update_controls()
if step_id == "statement" and self._latex_warning_paths:
self.after_idle(self._show_current_latex_warnings)
if step_id == "personal_read":
if status == "success":
self.info_var.set("Commande terminée. Poursuivez la relecture, puis cliquez sur « Marquer réussie ».")
+1 -1
View File
@@ -706,7 +706,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"personal_deploy",
"Étapes personnelles",
"Déployer et publier les copies",
"Étape manuelle : déployer miqmacs-copies-assets puis mettre à jour les copies depuis l’administration.",
"Étape manuelle : déployer miqmacs-copies-assets et la section `Interro` (inutile : mettre à jour les copies depuis l’administration).",
(manual("deploy"),),
personal=True,
),
+23
View File
@@ -9,6 +9,7 @@ from copienator_gui.app import CopienatorApp
from copienator_gui.workflow import command_display
from copienator.commands.page_splitter import _selected_inputs
from copienator import EvaluationWorkspace
from copienator.utils import LATEX_WARNING_MARKER
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
@@ -99,6 +100,28 @@ class GuiConvenienceTests(unittest.TestCase):
self.assertEqual(self.app.state_store.step("statement_persp")["status"], expected)
self.assertEqual(self.app.state_store.step("statement_groups")["status"], "ready")
def test_statement_latex_warnings_are_tracked_for_both_variants(self):
for variant in ("gemini", "personal"):
with self.subTest(variant=variant):
self.app._reset_latex_warnings()
self.app.state_store.update_step("statement", last_run_variant=variant)
self.app.active_step_id = "statement"
first = self.evaluation / "Text2" / "Ex 1.pdf"
second = self.evaluation / "Sol2" / "Ex 2.pdf"
marker = LATEX_WARNING_MARKER
self.app._track_latex_warnings(f"noise\n{marker}{first}")
self.assertEqual(self.app._latex_warning_paths, [])
self.app._track_latex_warnings(f"\n{marker}{second}\n{marker}{first}\n")
self.assertEqual(self.app._latex_warning_paths, [first, second])
self.assertTrue(self.app.latex_warning_button.winfo_manager())
with patch.object(self.app, "after_idle") as after_idle:
self.app._finish_process(4, False)
self.assertTrue(
any(call.args[0] == self.app._show_current_latex_warnings
for call in after_idle.call_args_list)
)
self.assertIn("2 problème(s) LaTeX", self.app.info_var.get())
def test_evaluation_buttons_are_ordered_and_open_folder_and_statement(self):
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
self.assertEqual(
+12
View File
@@ -1785,6 +1785,18 @@ class StandardCliTests(unittest.TestCase):
r"valid $\begin{cases} A=0 \\ B=1 \end{cases}$",
)
self.assertEqual(module.clean_string(cleaned_text, {}), cleaned_text)
percent_text = r"Code `n % p`, prose 50%, math $n \% p$ and $50%$."
escaped_percent_text = (
r"Code `n \% p`, prose 50\%, math $n \% p$ and $50\%$."
)
self.assertEqual(
module.clean_string(percent_text, {}),
escaped_percent_text,
)
self.assertEqual(
module.clean_string(escaped_percent_text, {}),
escaped_percent_text,
)
corrupted_json_escapes = (
"Formulas $"
+ "\x0c"
+2
View File
@@ -38,6 +38,7 @@ class CompileToPdfTests(unittest.TestCase):
self.assertEqual(output.read_bytes(), b"partial")
self.assertIn("Warning: LaTeX compilation reported errors", console.getvalue())
self.assertIn(utils.LATEX_WARNING_MARKER + str(output.resolve()), console.getvalue())
self.assertIn("a PDF was generated", console.getvalue())
self.assertIn("missing \\item", console.getvalue())
@@ -69,6 +70,7 @@ class CompileToPdfTests(unittest.TestCase):
self.assertFalse(output.exists())
self.assertIn("produced no PDF", console.getvalue())
self.assertIn(utils.LATEX_WARNING_MARKER + str(output.resolve()), console.getvalue())
if __name__ == "__main__":