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 dossier `Example` contient deux exemples de copies pour illustrer
le résultat final. 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 ** Avertissements
- 90% du code du repertoire a été vibe codé, sans vérification autre - 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 from __future__ import annotations
import argparse import argparse
import hashlib
import io import io
import json import json
import re import re
@@ -1016,24 +1017,35 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group
save_missing(path, '\n\n'.join(contents)) save_missing(path, '\n\n'.join(contents))
print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...") print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...")
# ========================================== compilation_status = compile_existing_pdfs(workspace)
# PDF COMPILATION (4 Threads) return ExitCode.PARTIAL if processing_errors or compilation_status != ExitCode.SUCCESS else ExitCode.SUCCESS
# ==========================================
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
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: def compile_worker(tex_file: Path) -> str | None:
"""Helper to read content and call the utility function.""" """Helper to read content and call the utility function."""
try: try:
pdf_path = tex_file.with_suffix('.pdf') pdf_path = tex_file.with_suffix('.pdf')
status_path = folder / 'Cache' / 'statement_pdf_status' / tex_file.parent.name / (tex_file.stem + '.json') 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 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): and pdf_path.stat().st_mtime_ns >= tex_file.stat().st_mtime_ns and not failed_before):
return None 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') content = tex_file.read_text(encoding='utf-8')
compile_to_pdf(content, pdf_path, strict=True) compile_to_pdf(content, pdf_path, strict=True, source_path=source_path)
atomic_write_json(status_path, {'complete': True}) atomic_write_json(status_path, {'complete': True, 'preamble_hash': fingerprint})
except Exception as e: # noqa: BLE001 - compiler worker boundary except Exception as e: # noqa: BLE001 - compiler worker boundary
return f"Error compiling {tex_file.name}: {e}" return f"Error compiling {tex_file.name}: {e}"
return None return None
@@ -1045,8 +1057,7 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group
] ]
for error in compile_errors: for error in compile_errors:
report_error(folder, 'PDF compilation', error) report_error(folder, 'PDF compilation', error)
processing_errors.extend(compile_errors) return ExitCode.PARTIAL if compile_errors else ExitCode.SUCCESS
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
@@ -1056,6 +1067,8 @@ def build_parser() -> argparse.ArgumentParser:
help="Regroup existing questions with Gemini; update only label_groups") help="Regroup existing questions with Gemini; update only label_groups")
actions.add_argument("--persp-only", action="store_true", actions.add_argument("--persp-only", action="store_true",
help="Generate only missing Persp rubrics for existing groups") 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', parser.add_argument('--delete-persp', action='store_true',
help='Delete current Persp before generation (requires --persp-only)') help='Delete current Persp before generation (requires --persp-only)')
parser.add_argument( parser.add_argument(
@@ -1071,6 +1084,10 @@ def main(argv: Sequence[str] | None = None) -> int:
def run(args): def run(args):
if args.delete_persp and not args.persp_only: if args.delete_persp and not args.persp_only:
raise CliError('--delete-persp requires --persp-only', ExitCode.INVALID_ARGUMENTS) 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: if args.groups_only or args.persp_only:
kwargs = {'delete_persp': True} if args.delete_persp else {} kwargs = {'delete_persp': True} if args.delete_persp else {}
return refine_existing(workspace_from_args(args), 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()}") 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.""" """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: with tempfile.TemporaryDirectory() as temp_dir:
tex_filename = 'text.tex' tex_filename = 'text.tex'
@@ -170,6 +204,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
result = subprocess.run( result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename], ['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir, cwd=temp_dir,
env=env,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
@@ -180,6 +215,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
result = subprocess.run( result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename], ['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir, cwd=temp_dir,
env=env,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, 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) generated_pdf = os.path.join(temp_dir, pdf_filename)
pdf_generated = os.path.exists(generated_pdf) 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) shutil.move(generated_pdf, output_pdf_path)
if result.returncode != 0: if result.returncode != 0:
+12 -5
View File
@@ -65,8 +65,15 @@ class EvaluationWorkspace:
) )
if not path.is_dir(): if not path.is_dir():
return False return False
aliases = (f"{path.name}.pdf", f"{path.name}.tex", f"{path.name}c.tex") workspace = EvaluationWorkspace(path)
return any((path / marker).exists() for marker in (*markers, *aliases)) 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 @property
def name(self) -> str: def name(self) -> str:
@@ -75,9 +82,9 @@ class EvaluationWorkspace:
def input_candidates(self, canonical_name: str) -> tuple[Path, ...]: def input_candidates(self, canonical_name: str) -> tuple[Path, ...]:
"""Return preferred paths for one of the three statement inputs.""" """Return preferred paths for one of the three statement inputs."""
aliases = { aliases = {
"enonce.pdf": ("enonce.pdf", "énoncé.pdf", f"{self.name}.pdf"), "enonce.pdf": ("enonce.pdf", "énonce.pdf", "énoncé.pdf", f"{self.name}.pdf"),
"enonce.tex": ("enonce.tex", f"{self.name}.tex"), "enonce.tex": ("enonce.tex", "énonce.tex", "énoncé.tex", f"{self.name}.tex"),
"correction.tex": ("correction.tex", f"{self.name}c.tex"), "correction.tex": ("correction.tex", "corrige.tex", "corrigé.tex", f"{self.name}c.tex"),
} }
names = aliases.get(canonical_name, (canonical_name,)) names = aliases.get(canonical_name, (canonical_name,))
return tuple(self.root / name for name in names) 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", "Vérifier les fichiers d’entrée",
"Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex, ou, " "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. " "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.", "Le fichier names doit être présent dans l’évaluation ou à la racine du projet.",
(manual("check"),), (manual("check"),),
requires=("enonce.pdf", "enonce.tex", "correction.tex"), 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. ", "Génère aussi des barèmes détaillés de chaque question. ",
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),) ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
if show_personal_steps else ()) 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=( arguments=(
arg_target("le dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
+33 -2
View File
@@ -127,8 +127,9 @@ class WorkspaceTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "A" root = Path(directory) / "A"
root.mkdir() root.mkdir()
for name in ("enonce.pdf", "A.pdf", "enonce.tex", "A.tex", for name in ("enonce.pdf", "énonce.pdf", "énoncé.pdf", "A.pdf",
"correction.tex", "Ac.tex"): "enonce.tex", "énonce.tex", "énoncé.tex", "A.tex",
"correction.tex", "corrige.tex", "corrigé.tex", "Ac.tex"):
(root / name).touch() (root / name).touch()
workspace = EvaluationWorkspace(root) workspace = EvaluationWorkspace(root)
@@ -136,6 +137,30 @@ class WorkspaceTests(unittest.TestCase):
self.assertEqual(workspace.statement_source_file, root / "enonce.tex") self.assertEqual(workspace.statement_source_file, root / "enonce.tex")
self.assertEqual(workspace.correction_source_file, root / "correction.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: def test_discover_and_require_report_clear_errors(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@@ -2259,6 +2284,12 @@ class StandardCliTests(unittest.TestCase):
class WorkflowTests(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: def setUp(self) -> None:
self.steps = {step.id: step for step in build_workflow(True)} self.steps = {step.id: step for step in build_workflow(True)}
self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example") self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example")
+27
View File
@@ -151,6 +151,33 @@ class FullStatementResumeTests(unittest.TestCase):
response(contexts=[]), response(contexts=[]),
response(rubrics=[{'label': 'Ex 1', 'rubric_content': '4 points : correct'}])] 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): def test_rubric_failure_retry_keeps_grouping_edits_and_sources(self):
self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')] self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')]
self.assertEqual(self.run_exam(), ExitCode.PARTIAL) self.assertEqual(self.run_exam(), ExitCode.PARTIAL)
+56
View File
@@ -1,6 +1,7 @@
import contextlib import contextlib
import io import io
import subprocess import subprocess
import shutil
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -10,6 +11,61 @@ from copienator import utils
class CompileToPdfTests(unittest.TestCase): 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): def test_strict_compilation_propagates_failure_with_context(self):
result = subprocess.CompletedProcess(args=[], returncode=1, result = subprocess.CompletedProcess(args=[], returncode=1,
stdout="! Undefined control sequence.\nl.13 broken command\n") stdout="! Undefined control sequence.\nl.13 broken command\n")