utilities to fix LaTeX issues
This commit is contained in:
1 parent
2a4c5f4f7c
commit
a56fc6e680
7 files changed
+164
-2
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
@@ -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 ».")
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user