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)
|
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:
|
def normalize_overescaped_latex_commands(text: str) -> str:
|
||||||
r"""Collapse doubled command escapes inside LaTeX math environments.
|
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 = fast_fix(text, lookup).replace("\x00", "")
|
||||||
text = repair_json_escape_corruption(text)
|
text = repair_json_escape_corruption(text)
|
||||||
text = normalize_overescaped_latex_commands(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:
|
def clean_obj(value: Any, lookup: dict[str, str]) -> Any:
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ import subprocess
|
|||||||
|
|
||||||
from .configuration import LATEX_AFTER, LATEX_BEFORE
|
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):
|
def compile_to_pdf(text, output_pdf_path, *, strict=False):
|
||||||
"""Wraps text in standalone header/footer templates and compiles it to PDF."""
|
"""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)
|
shutil.move(generated_pdf, output_pdf_path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
report_latex_warning(output_pdf_path)
|
||||||
lines = result.stdout.splitlines()
|
lines = result.stdout.splitlines()
|
||||||
first = next((i for i, line in enumerate(lines) if line.lstrip().startswith("!")), None)
|
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 ""
|
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:
|
if strict:
|
||||||
raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}")
|
raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}")
|
||||||
elif not pdf_generated:
|
elif not pdf_generated:
|
||||||
|
report_latex_warning(output_pdf_path)
|
||||||
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}")
|
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}")
|
||||||
if strict:
|
if strict:
|
||||||
raise RuntimeError(f"No PDF generated for {output_pdf_path}")
|
raise RuntimeError(f"No PDF generated for {output_pdf_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if strict:
|
if strict:
|
||||||
raise
|
raise
|
||||||
|
report_latex_warning(output_pdf_path)
|
||||||
print(f"Compilation error for {output_pdf_path}: {e}")
|
print(f"Compilation error for {output_pdf_path}: {e}")
|
||||||
@@ -20,6 +20,7 @@ from copienator.platform import (
|
|||||||
open_path,
|
open_path,
|
||||||
validate_windows_labels,
|
validate_windows_labels,
|
||||||
)
|
)
|
||||||
|
from copienator.utils import LATEX_WARNING_MARKER
|
||||||
|
|
||||||
from .diagnostics import collect_diagnostics
|
from .diagnostics import collect_diagnostics
|
||||||
from .batch_monitor import BatchMonitor
|
from .batch_monitor import BatchMonitor
|
||||||
@@ -281,6 +282,8 @@ class CopienatorApp(tk.Tk):
|
|||||||
self.pending_refaire_commands: list[list[str]] = []
|
self.pending_refaire_commands: list[list[str]] = []
|
||||||
self.refaire_command_index = 0
|
self.refaire_command_index = 0
|
||||||
self._correction_progress_buffer = ""
|
self._correction_progress_buffer = ""
|
||||||
|
self._latex_warning_buffer = ""
|
||||||
|
self._latex_warning_paths: list[Path] = []
|
||||||
|
|
||||||
self.title("Copienator — assistant de correction")
|
self.title("Copienator — assistant de correction")
|
||||||
self.geometry("1180x820")
|
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))
|
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 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)
|
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))
|
ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6))
|
||||||
self.stdin_var = tk.StringVar()
|
self.stdin_var = tk.StringVar()
|
||||||
self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var)
|
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.")
|
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
|
||||||
return
|
return
|
||||||
self.batch_monitor.stop()
|
self.batch_monitor.stop()
|
||||||
|
self._reset_latex_warnings()
|
||||||
self._save_current_form()
|
self._save_current_form()
|
||||||
self.state_store.load(evaluation)
|
self.state_store.load(evaluation)
|
||||||
for step in self.steps:
|
for step in self.steps:
|
||||||
@@ -1532,6 +1543,8 @@ class CopienatorApp(tk.Tk):
|
|||||||
last_run_values=run_values,
|
last_run_values=run_values,
|
||||||
)
|
)
|
||||||
self.active_step_id = step.id
|
self.active_step_id = step.id
|
||||||
|
if step.id == "statement":
|
||||||
|
self._reset_latex_warnings()
|
||||||
if step.id == "correction":
|
if step.id == "correction":
|
||||||
self._correction_progress_buffer = ""
|
self._correction_progress_buffer = ""
|
||||||
self.info_var.set("Correction : préparation des groupes…")
|
self.info_var.set("Correction : préparation des groupes…")
|
||||||
@@ -1642,6 +1655,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
if event == "output":
|
if event == "output":
|
||||||
output = str(payload)
|
output = str(payload)
|
||||||
self._track_correction_progress(output)
|
self._track_correction_progress(output)
|
||||||
|
self._track_latex_warnings(output)
|
||||||
self._append_console(output)
|
self._append_console(output)
|
||||||
elif event == "input_echo":
|
elif event == "input_echo":
|
||||||
self._append_console(f"> {payload}")
|
self._append_console(f"> {payload}")
|
||||||
@@ -1670,6 +1684,88 @@ class CopienatorApp(tk.Tk):
|
|||||||
f"({percent} %)."
|
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:
|
def _batch_watch_status_changed(self, message: str) -> None:
|
||||||
self.batch_watch_status.set(message)
|
self.batch_watch_status.set(message)
|
||||||
self._update_controls()
|
self._update_controls()
|
||||||
@@ -1710,6 +1806,8 @@ class CopienatorApp(tk.Tk):
|
|||||||
step_id = self.active_step_id
|
step_id = self.active_step_id
|
||||||
if not step_id:
|
if not step_id:
|
||||||
return
|
return
|
||||||
|
if step_id == "statement":
|
||||||
|
self._track_latex_warnings("", flush=True)
|
||||||
status = process_status(return_code, interrupted)
|
status = process_status(return_code, interrupted)
|
||||||
if status == "success" and self.pending_refaire_commands:
|
if status == "success" and self.pending_refaire_commands:
|
||||||
command = self.pending_refaire_commands.pop(0)
|
command = self.pending_refaire_commands.pop(0)
|
||||||
@@ -1790,9 +1888,17 @@ class CopienatorApp(tk.Tk):
|
|||||||
title = self.step_by_id[step_id].title
|
title = self.step_by_id[step_id].title
|
||||||
self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n")
|
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}).")
|
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.active_step_id = None
|
||||||
self._populate_tree()
|
self._populate_tree()
|
||||||
self._update_controls()
|
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 step_id == "personal_read":
|
||||||
if status == "success":
|
if status == "success":
|
||||||
self.info_var.set("Commande terminée. Poursuivez la relecture, puis cliquez sur « Marquer réussie ».")
|
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",
|
"personal_deploy",
|
||||||
"Étapes personnelles",
|
"Étapes personnelles",
|
||||||
"Déployer et publier les copies",
|
"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"),),
|
(manual("deploy"),),
|
||||||
personal=True,
|
personal=True,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from copienator_gui.app import CopienatorApp
|
|||||||
from copienator_gui.workflow import command_display
|
from copienator_gui.workflow import command_display
|
||||||
from copienator.commands.page_splitter import _selected_inputs
|
from copienator.commands.page_splitter import _selected_inputs
|
||||||
from copienator import EvaluationWorkspace
|
from copienator import EvaluationWorkspace
|
||||||
|
from copienator.utils import LATEX_WARNING_MARKER
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
|
@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_persp")["status"], expected)
|
||||||
self.assertEqual(self.app.state_store.step("statement_groups")["status"], "ready")
|
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):
|
def test_evaluation_buttons_are_ordered_and_open_folder_and_statement(self):
|
||||||
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
|
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -1785,6 +1785,18 @@ class StandardCliTests(unittest.TestCase):
|
|||||||
r"valid $\begin{cases} A=0 \\ B=1 \end{cases}$",
|
r"valid $\begin{cases} A=0 \\ B=1 \end{cases}$",
|
||||||
)
|
)
|
||||||
self.assertEqual(module.clean_string(cleaned_text, {}), cleaned_text)
|
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 = (
|
corrupted_json_escapes = (
|
||||||
"Formulas $"
|
"Formulas $"
|
||||||
+ "\x0c"
|
+ "\x0c"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class CompileToPdfTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(output.read_bytes(), b"partial")
|
self.assertEqual(output.read_bytes(), b"partial")
|
||||||
self.assertIn("Warning: LaTeX compilation reported errors", console.getvalue())
|
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("a PDF was generated", console.getvalue())
|
||||||
self.assertIn("missing \\item", console.getvalue())
|
self.assertIn("missing \\item", console.getvalue())
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ class CompileToPdfTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertFalse(output.exists())
|
self.assertFalse(output.exists())
|
||||||
self.assertIn("produced no PDF", console.getvalue())
|
self.assertIn("produced no PDF", console.getvalue())
|
||||||
|
self.assertIn(utils.LATEX_WARNING_MARKER + str(output.resolve()), console.getvalue())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in new issue
Block a user