Personal tex compilation fixes

This commit is contained in:
sebastien committed 2026-10-10 10:01:16 +02:00
1 parent ec6ac9580b
commit 705db1068f
8 files changed
+217 -21

No files matched your search

+18
View File
@@ -26,6 +26,24 @@ pour faire corriger des copies par Gemini.
Le dossier `Example` contient deux exemples de copies pour illustrer
le résultat final.
** Reprendre une compilation LaTeX après l'analyse
Les PDF des questions et solutions reprennent automatiquement les packages et
macros de =enonce.tex= et du corrigé, avec le format recadré de Copienator.
La géométrie des pages du document original est exclue pour préserver le recadrage.
Les packages et fichiers locaux doivent rester accessibles à LaTeX.
Pour recompiler les fragments déjà extraits sans aucune requête Gemini :
#+BEGIN_SRC bash
python -m copienator statement DSI01 --compile-only
#+END_SRC
Dans l'interface, choisir « Recompiler les PDF sans Gemini » à l'étape
« Analyser l’énoncé ». Cette action conserve les sources, les groupes et les
barèmes. Elle reprend les échecs et recompile si les fragments ou leur préambule
ont changé. Une compilation en erreur ne remplace pas le PDF précédent.
** Avertissements
- 90% du code du repertoire a été vibe codé, sans vérification autre
+27 -10
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import hashlib
import io
import json
import re
@@ -1016,24 +1017,35 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group
save_missing(path, '\n\n'.join(contents))
print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...")
# ==========================================
# PDF COMPILATION (4 Threads)
# ==========================================
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
compilation_status = compile_existing_pdfs(workspace)
return ExitCode.PARTIAL if processing_errors or compilation_status != ExitCode.SUCCESS else ExitCode.SUCCESS
def compile_existing_pdfs(workspace: EvaluationWorkspace) -> ExitCode:
"""Compile existing fragments without reading checkpoints or calling Gemini."""
folder = workspace.root
all_tex_files = list(workspace.rendered_text_dir.glob("*.tex")) + list(workspace.rendered_solution_dir.glob("*.tex"))
def compile_worker(tex_file: Path) -> str | None:
"""Helper to read content and call the utility function."""
try:
pdf_path = tex_file.with_suffix('.pdf')
status_path = folder / 'Cache' / 'statement_pdf_status' / tex_file.parent.name / (tex_file.stem + '.json')
failed_before = status_path.exists() and not json.loads(status_path.read_text()).get('complete')
source_path = (workspace.correction_source_file if tex_file.parent.name == 'Sol2'
else workspace.statement_source_file)
if not source_path.is_file():
source_path = None
fingerprint = hashlib.sha256((utils.fragment_latex_header(source_path)
+ utils.LATEX_AFTER + str(source_path)).encode()).hexdigest()
status = json.loads(status_path.read_text()) if status_path.exists() else {}
failed_before = not status.get('complete') or status.get('preamble_hash') != fingerprint
if (pdf_path.is_file() and pdf_path.stat().st_size > 0
and pdf_path.stat().st_mtime_ns >= tex_file.stat().st_mtime_ns and not failed_before):
return None
atomic_write_json(status_path, {'complete': False})
atomic_write_json(status_path, {'complete': False, 'preamble_hash': fingerprint})
content = tex_file.read_text(encoding='utf-8')
compile_to_pdf(content, pdf_path, strict=True)
atomic_write_json(status_path, {'complete': True})
compile_to_pdf(content, pdf_path, strict=True, source_path=source_path)
atomic_write_json(status_path, {'complete': True, 'preamble_hash': fingerprint})
except Exception as e: # noqa: BLE001 - compiler worker boundary
return f"Error compiling {tex_file.name}: {e}"
return None
@@ -1045,8 +1057,7 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group
]
for error in compile_errors:
report_error(folder, 'PDF compilation', error)
processing_errors.extend(compile_errors)
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
return ExitCode.PARTIAL if compile_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
@@ -1056,6 +1067,8 @@ def build_parser() -> argparse.ArgumentParser:
help="Regroup existing questions with Gemini; update only label_groups")
actions.add_argument("--persp-only", action="store_true",
help="Generate only missing Persp rubrics for existing groups")
actions.add_argument("--compile-only", action="store_true",
help="Retry existing fragment PDFs using source preambles; no Gemini requests")
parser.add_argument('--delete-persp', action='store_true',
help='Delete current Persp before generation (requires --persp-only)')
parser.add_argument(
@@ -1071,6 +1084,10 @@ def main(argv: Sequence[str] | None = None) -> int:
def run(args):
if args.delete_persp and not args.persp_only:
raise CliError('--delete-persp requires --persp-only', ExitCode.INVALID_ARGUMENTS)
if args.restart and (args.compile_only or args.groups_only or args.persp_only):
raise CliError('--restart cannot be combined with a partial action', ExitCode.INVALID_ARGUMENTS)
if args.compile_only:
return compile_existing_pdfs(workspace_from_args(args))
if args.groups_only or args.persp_only:
kwargs = {'delete_persp': True} if args.delete_persp else {}
return refine_existing(workspace_from_args(args),
+39 -3
View File
@@ -149,9 +149,43 @@ def report_latex_warning(output_pdf_path) -> None:
print(f"{LATEX_WARNING_MARKER}{Path(output_pdf_path).resolve()}")
def compile_to_pdf(text, output_pdf_path, *, strict=False):
def fragment_latex_header(source_path=None):
"""Keep source packages/macros while using the configured fragment layout."""
if source_path is None:
return LATEX_BEFORE
source = Path(source_path).read_text(encoding="utf-8")
# Ignore commented document commands when locating the preamble.
source = re.sub(r"(?m)(?<!\\)%[^\n]*", "", source)
begin = re.search(r"\\begin\s*\{document\}", source)
document_class = r"\\documentclass(?:\s*\[[^\]]*\])?\s*\{[^}]+\}"
configured_class = re.search(document_class, LATEX_BEFORE)
configured_begin = re.search(r"\\begin\s*\{document\}", LATEX_BEFORE)
if begin is None or configured_class is None or configured_begin is None:
return LATEX_BEFORE
preamble = source[:begin.start()]
if not re.search(document_class, preamble):
return LATEX_BEFORE
preamble = re.sub(document_class, lambda _: configured_class.group(), preamble, count=1)
# geometry overrides standalone's crop coordinates and can produce blank or
# clipped PDFs even when LaTeX succeeds. Keep other source packages intact.
def without_geometry(match):
packages = [name.strip() for name in match.group(2).split(',')
if name.strip() != 'geometry']
return (r'\usepackage' + (match.group(1) or '') + '{' + ','.join(packages) + '}'
if packages else '')
preamble = re.sub(r'\\usepackage\s*(\[[^\]]*\])?\s*\{([^}]+)\}',
without_geometry, preamble)
preamble = re.sub(r'\\(?:geometry|newgeometry)\s*\{[^}]*\}', '', preamble)
return preamble + LATEX_BEFORE[configured_begin.start():]
def compile_to_pdf(text, output_pdf_path, *, strict=False, source_path=None):
"""Wraps text in standalone header/footer templates and compiles it to PDF."""
latex_template = f"{LATEX_BEFORE}{text}{LATEX_AFTER}"
latex_template = f"{fragment_latex_header(source_path)}{text}{LATEX_AFTER}"
env = os.environ.copy()
if source_path is not None:
source_dir = str(Path(source_path).resolve().parent)
env['TEXINPUTS'] = source_dir + os.pathsep + env.get('TEXINPUTS', '')
with tempfile.TemporaryDirectory() as temp_dir:
tex_filename = 'text.tex'
@@ -170,6 +204,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
@@ -180,6 +215,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
@@ -188,7 +224,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
generated_pdf = os.path.join(temp_dir, pdf_filename)
pdf_generated = os.path.exists(generated_pdf)
if pdf_generated:
if pdf_generated and (not strict or result.returncode == 0):
shutil.move(generated_pdf, output_pdf_path)
if result.returncode != 0:
+12 -5
View File
@@ -65,8 +65,15 @@ class EvaluationWorkspace:
)
if not path.is_dir():
return False
aliases = (f"{path.name}.pdf", f"{path.name}.tex", f"{path.name}c.tex")
return any((path / marker).exists() for marker in (*markers, *aliases))
workspace = EvaluationWorkspace(path)
inputs = (
candidate
for name in ("enonce.pdf", "enonce.tex", "correction.tex")
for candidate in workspace.input_candidates(name)
)
return any((path / marker).exists() for marker in markers) or any(
candidate.exists() for candidate in inputs
)
@property
def name(self) -> str:
@@ -75,9 +82,9 @@ class EvaluationWorkspace:
def input_candidates(self, canonical_name: str) -> tuple[Path, ...]:
"""Return preferred paths for one of the three statement inputs."""
aliases = {
"enonce.pdf": ("enonce.pdf", "énoncé.pdf", f"{self.name}.pdf"),
"enonce.tex": ("enonce.tex", f"{self.name}.tex"),
"correction.tex": ("correction.tex", f"{self.name}c.tex"),
"enonce.pdf": ("enonce.pdf", "énonce.pdf", "énoncé.pdf", f"{self.name}.pdf"),
"enonce.tex": ("enonce.tex", "énonce.tex", "énoncé.tex", f"{self.name}.tex"),
"correction.tex": ("correction.tex", "corrige.tex", "corrigé.tex", f"{self.name}c.tex"),
}
names = aliases.get(canonical_name, (canonical_name,))
return tuple(self.root / name for name in names)
+5 -1
View File
@@ -143,6 +143,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Vérifier les fichiers d’entrée",
"Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex, ou, "
"s’il s’appelle A, les fichiers A.pdf, A.tex et Ac.tex. "
"Les noms énonce.pdf/tex, énoncé.pdf/tex et corrige.tex/corrigé.tex "
"sont également acceptés. "
"Le fichier names doit être présent dans l’évaluation ou à la racine du projet.",
(manual("check"),),
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
@@ -156,7 +158,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Génère aussi des barèmes détaillés de chaque question. ",
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
if show_personal_steps else ())
+ (python("gemini", "Analyse avec Gemini", "statement"),),
+ (python("gemini", "Analyse avec Gemini", "statement"),
python("compile", "Recompiler les PDF sans Gemini", "statement",
fixed_args=("--compile-only",))),
arguments=(
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
+33 -2
View File
@@ -127,8 +127,9 @@ class WorkspaceTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "A"
root.mkdir()
for name in ("enonce.pdf", "A.pdf", "enonce.tex", "A.tex",
"correction.tex", "Ac.tex"):
for name in ("enonce.pdf", "énonce.pdf", "énoncé.pdf", "A.pdf",
"enonce.tex", "énonce.tex", "énoncé.tex", "A.tex",
"correction.tex", "corrige.tex", "corrigé.tex", "Ac.tex"):
(root / name).touch()
workspace = EvaluationWorkspace(root)
@@ -136,6 +137,30 @@ class WorkspaceTests(unittest.TestCase):
self.assertEqual(workspace.statement_source_file, root / "enonce.tex")
self.assertEqual(workspace.correction_source_file, root / "correction.tex")
def test_alternative_statement_inputs_are_detected_and_validated(self) -> None:
for statement in ("énonce", "énoncé"):
for correction in ("corrige", "corrigé"):
with self.subTest(statement=statement, correction=correction), tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "Exam"
root.mkdir()
for name in (f"{statement}.pdf", f"{statement}.tex", f"{correction}.tex"):
(root / name).touch()
workspace = EvaluationWorkspace.discover(root)
workspace.require_files("enonce.pdf", "enonce.tex", "correction.tex")
self.assertEqual(workspace.statement_pdf_file, root / f"{statement}.pdf")
self.assertEqual(workspace.statement_source_file, root / f"{statement}.tex")
self.assertEqual(workspace.correction_source_file, root / f"{correction}.tex")
self.assertTrue(workspace.is_statement_pdf(root / f"{statement}.pdf"))
self.assertEqual(workspace.resolve("correction.tex"), root / f"{correction}.tex")
def test_discovery_recognizes_each_alternative_input_on_its_own(self) -> None:
for name in ("énonce.pdf", "énonce.tex", "énoncé.pdf", "énoncé.tex", "corrige.tex", "corrigé.tex"):
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "Exam"
root.mkdir()
(root / name).touch()
self.assertEqual(EvaluationWorkspace.discover(root).root, root)
def test_discover_and_require_report_clear_errors(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@@ -2259,6 +2284,12 @@ class StandardCliTests(unittest.TestCase):
class WorkflowTests(unittest.TestCase):
def test_statement_compile_variant_avoids_gemini_and_restart(self) -> None:
command = self.command('statement', 'compile',
{'target': self.evaluation, 'restart': True})
self.assertIn('--compile-only', command)
self.assertNotIn('--restart', command)
def setUp(self) -> None:
self.steps = {step.id: step for step in build_workflow(True)}
self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example")
+27
View File
@@ -151,6 +151,33 @@ class FullStatementResumeTests(unittest.TestCase):
response(contexts=[]),
response(rubrics=[{'label': 'Ex 1', 'rubric_content': '4 points : correct'}])]
def test_compile_only_never_enters_analysis_and_preserves_sources(self):
directory = self.root / 'Sol2'
directory.mkdir()
tex = directory / '1.tex'
tex.write_text('manually edited solution')
with patch.object(gemini, 'process_exam') as analysis, patch.object(
gemini, 'compile_to_pdf', self.compiler):
self.assertEqual(gemini.main([str(self.root), '--compile-only']), 0)
analysis.assert_not_called()
self.assertEqual(tex.read_text(), 'manually edited solution')
self.assertEqual((directory / '1.pdf').read_bytes(), b'PDF')
self.assertEqual(self.compiler.call_args.kwargs['source_path'], self.root / 'correction.tex')
def test_preamble_change_recompiles_previously_successful_pdfs(self):
self.client.models.generate_content.side_effect = self.responses()
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.compiler.reset_mock()
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.compiler.assert_not_called()
(self.root / 'correction.tex').write_text(
r'\documentclass{article}\newcommand{\custom}{new}\begin{document}')
self.client.models.generate_content.reset_mock()
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.client.models.generate_content.assert_not_called()
self.assertEqual(self.compiler.call_count, 1)
self.assertEqual(self.compiler.call_args.args[1], self.root / 'Sol2/Ex 1.pdf')
def test_rubric_failure_retry_keeps_grouping_edits_and_sources(self):
self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')]
self.assertEqual(self.run_exam(), ExitCode.PARTIAL)
+56
View File
@@ -1,6 +1,7 @@
import contextlib
import io
import subprocess
import shutil
import tempfile
import unittest
from pathlib import Path
@@ -10,6 +11,61 @@ from copienator import utils
class CompileToPdfTests(unittest.TestCase):
def test_source_preamble_keeps_macros_and_uses_fragment_layout(self):
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / 'source.tex'
source.write_text('% \\begin{document}\n\\documentclass{article}\n'
'\\usepackage{amsthm}\n\\newcommand{\\custom}{works}\n'
'\\begin{document}original body\\end{document}')
header = utils.fragment_latex_header(source)
self.assertIn('standalone', header)
self.assertIn(r'\usepackage{amsthm}', header)
self.assertIn(r'\newcommand{\custom}{works}', header)
self.assertNotIn('original body', header)
@unittest.skipUnless(shutil.which('pdflatex'), 'pdflatex is not installed')
def test_source_geometry_does_not_clip_fragment(self):
import pymupdf
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / 'source.tex'
source.write_text(r'\documentclass{article}'
r'\usepackage[margin=1cm]{geometry}'
r'\geometry{top=0.5cm}'
r'\newcommand{\custom}{FIRSTLINE}'
r'\begin{document}unused body\end{document}')
output = Path(directory) / 'out.pdf'
utils.compile_to_pdf(r'\custom\par LASTLINE', output,
source_path=source, strict=True)
with pymupdf.open(output) as doc:
self.assertIn('FIRSTLINE', doc[0].get_text())
self.assertIn('LASTLINE', doc[0].get_text())
for word in doc[0].get_text('words'):
self.assertGreaterEqual(word[1], 0)
self.assertLessEqual(word[3], doc[0].rect.height)
def test_source_directory_is_available_to_tex(self):
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / 'source.tex'
source.write_text(r'\documentclass{article}\begin{document}')
result = subprocess.CompletedProcess(args=[], returncode=1, stdout='! error')
with patch.object(utils.subprocess, 'run', return_value=result) as run:
with self.assertRaises(RuntimeError):
utils.compile_to_pdf('broken', Path(directory) / 'out.pdf',
source_path=source, strict=True)
self.assertTrue(run.call_args.kwargs['env']['TEXINPUTS'].startswith(directory))
def test_strict_failure_preserves_previous_pdf(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / 'out.pdf'
output.write_bytes(b'previous PDF')
def failed_run(*args, **kwargs):
(Path(kwargs['cwd']) / 'text.pdf').write_bytes(b'partial')
return subprocess.CompletedProcess(args=[], returncode=1, stdout='! error')
with patch.object(utils.subprocess, 'run', side_effect=failed_run):
with self.assertRaises(RuntimeError):
utils.compile_to_pdf('broken', output, strict=True)
self.assertEqual(output.read_bytes(), b'previous PDF')
def test_strict_compilation_propagates_failure_with_context(self):
result = subprocess.CompletedProcess(args=[], returncode=1,
stdout="! Undefined control sequence.\nl.13 broken command\n")