Windows compatibility
This commit is contained in:
+65
-14
@@ -1,7 +1,7 @@
|
|||||||
#+title: Script
|
#+title: Script
|
||||||
#+author: Sébastien Miquel
|
#+author: Sébastien Miquel
|
||||||
#+date: 14-03-2026
|
#+date: 14-03-2026
|
||||||
# Time-stamp: <20-08-26 09:59>
|
# Time-stamp: <20-08-26 11:37>
|
||||||
#+OPTIONS:
|
#+OPTIONS:
|
||||||
|
|
||||||
* Méta
|
* Méta
|
||||||
@@ -38,6 +38,23 @@ examples du rendu final (dans le sous dossier =BGnot=).
|
|||||||
Cette situation s'améliorera peut-être, mais faciliter l'utilisation
|
Cette situation s'améliorera peut-être, mais faciliter l'utilisation
|
||||||
de ce système n'est pas une priorité.
|
de ce système n'est pas une priorité.
|
||||||
|
|
||||||
|
** Limitations
|
||||||
|
|
||||||
|
Pour l'instant, la correction est faite question par question : le LLM
|
||||||
|
n'a accès qu'à la partie de la copie correspondant à une question
|
||||||
|
fixée. Ça ne gère donc pas les cas où un argument a été donné dans une
|
||||||
|
question précédente ou autre.
|
||||||
|
|
||||||
|
*** Noms de labels sous Windows
|
||||||
|
|
||||||
|
Certains labels sont utilisés directement comme noms de fichiers et de
|
||||||
|
dossiers. Les labels contenant notamment =:= ne sont pas acceptés par
|
||||||
|
Windows. Sous Windows, les scripts refusent ces labels avant de
|
||||||
|
poursuivre et affichent la liste des valeurs à corriger. Aucune
|
||||||
|
conversion silencieuse n'est appliquée ; utiliser par exemple
|
||||||
|
=Ex 1 - a)= au lieu de =Ex 1 : a)=. Le diagnostic du GUI permet de les
|
||||||
|
repérer avant de lancer une étape.
|
||||||
|
|
||||||
** Requirements
|
** Requirements
|
||||||
|
|
||||||
*** Python
|
*** Python
|
||||||
@@ -74,6 +91,34 @@ ou éventuellement, la renseigner directement dans le fichier
|
|||||||
|
|
||||||
Copier `default_config.py` en `config.py`. Éventuellement le modifier.
|
Copier `default_config.py` en `config.py`. Éventuellement le modifier.
|
||||||
|
|
||||||
|
*** Interface graphique
|
||||||
|
|
||||||
|
Lancer l'assistant avec :
|
||||||
|
|
||||||
|
#+BEGIN_SRC bash
|
||||||
|
python gui.py
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
On peut aussi ouvrir directement une évaluation avec
|
||||||
|
=python gui.py Interro=. L'interface conserve l'état et l'historique
|
||||||
|
des étapes dans =Interro/.copienator-gui.json=, et les sorties complètes
|
||||||
|
dans =Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
|
||||||
|
supprime aucun résultat ; les étapes suivantes sont seulement marquées
|
||||||
|
comme étant à revalider.
|
||||||
|
|
||||||
|
La variable =SHOW_PERSONAL_STEPS= de =config.py= permet d'afficher ou de
|
||||||
|
masquer les étapes propres au workflow personnel.
|
||||||
|
|
||||||
|
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
|
||||||
|
PDF Arranger et la configuration Gemini. Sous Windows, les exécutables
|
||||||
|
externes doivent être accessibles depuis =PATH=. Quand la création de
|
||||||
|
liens symboliques ou physiques n'est pas autorisée, l'export et la
|
||||||
|
préparation de =A Rendre= utilisent automatiquement une copie normale.
|
||||||
|
|
||||||
|
Les chemins des étapes personnelles peuvent être adaptés avec
|
||||||
|
=CURRENT_SCORE_ODS_PATH=, =FINAL_SCORE_ODS_PATH=,
|
||||||
|
=FINAL_SCORE_OUTPUT_DIR= et =FINAL_SCORE_FONT_PATH= dans =config.py=.
|
||||||
|
|
||||||
** Correction d'un paquet de copies
|
** Correction d'un paquet de copies
|
||||||
|
|
||||||
1. Créer un fichier =names= dans le dossier courant, avec les
|
1. Créer un fichier =names= dans le dossier courant, avec les
|
||||||
@@ -82,7 +127,12 @@ Copier `default_config.py` en `config.py`. Éventuellement le modifier.
|
|||||||
suite)
|
suite)
|
||||||
3. Suivre les instructions suivantes
|
3. Suivre les instructions suivantes
|
||||||
|
|
||||||
|
|
||||||
* Étapes et Script
|
* Étapes et Script
|
||||||
|
|
||||||
|
Utiliser `python gui.py` ou `python gui.py Interro` pour lancer un GUI
|
||||||
|
qui suit automatiquement les étapes décrites ci-dessous.
|
||||||
|
|
||||||
** Prétraitement de l'énoncé
|
** Prétraitement de l'énoncé
|
||||||
|
|
||||||
Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation :
|
Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation :
|
||||||
@@ -127,11 +177,11 @@ Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation
|
|||||||
|
|
||||||
Mettre les copies scannées au format pdf dans =Interro=.
|
Mettre les copies scannées au format pdf dans =Interro=.
|
||||||
|
|
||||||
1. =./rotate_all.sh Interro= (facultatif)
|
1. =python copies_tools.py rotate Interro= (facultatif)
|
||||||
|
|
||||||
Retourne tous les pdf de 180°, si la photocopie a été faite à
|
Retourne tous les pdf de 180°, si la photocopie a été faite à
|
||||||
l'envers.
|
l'envers.
|
||||||
2. =./rename_to_copie.sh Interro=
|
2. =python copies_tools.py rename Interro=
|
||||||
change le nom des copies en =Copie{id}.pdf=
|
change le nom des copies en =Copie{id}.pdf=
|
||||||
3. =python page_splitter.py Interro=
|
3. =python page_splitter.py Interro=
|
||||||
|
|
||||||
@@ -198,7 +248,6 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
|
|||||||
2. =python correction.py Interro --limit 240= OU
|
2. =python correction.py Interro --limit 240= OU
|
||||||
=python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
|
=python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
|
||||||
=python correction.py Interro --overwrite=
|
=python correction.py Interro --overwrite=
|
||||||
=python correction.py Interro --pro-by-label= (needs `labels_for_pro`)
|
|
||||||
|
|
||||||
Fais les requêtes de correction à Gemini.
|
Fais les requêtes de correction à Gemini.
|
||||||
|
|
||||||
@@ -259,20 +308,20 @@ OU
|
|||||||
3. =python export.py Interro= (gestion perso)
|
3. =python export.py Interro= (gestion perso)
|
||||||
Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
|
Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
|
||||||
(par défaut =Export=).
|
(par défaut =Export=).
|
||||||
- Les mettre dans le dossier racine de la tablette, et renommer en =aaa=.
|
|
||||||
- Vider =Syncthing/Annotées= sur la tablette et localement.
|
Il faut ensuite annoter les fichiers dans `EXPORT_DIR` avec une
|
||||||
À automatiser, aussi c'est lent…
|
tablette graphique.
|
||||||
|
|
||||||
** Lecture de la correction manuscrite
|
** Lecture de la correction manuscrite
|
||||||
|
|
||||||
1. =python import.py Interro= (gestion perso)
|
_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
|
||||||
|
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette.
|
||||||
|
|
||||||
_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
|
1. =python import.py Interro=
|
||||||
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette.
|
|
||||||
|
|
||||||
Une fois les corrections manuelles appliquées aux fichiers
|
Une fois les corrections manuelles appliquées aux fichiers
|
||||||
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
|
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
|
||||||
sous le nom =Concat_annotated.pdf=.
|
sous le nom =Concat_annotated.pdf=.
|
||||||
|
|
||||||
2. =python reading_annotations.py Interro=
|
2. =python reading_annotations.py Interro=
|
||||||
|
|
||||||
@@ -290,8 +339,9 @@ OU
|
|||||||
+ un fichier =score.json= qui contient les notes par question
|
+ un fichier =score.json= qui contient les notes par question
|
||||||
|
|
||||||
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans.
|
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans.
|
||||||
|
4. Éventuellement, faire des modifications manuelles aux =score.json=.
|
||||||
|
|
||||||
4. On peut faire des changements manuels aux =score.json= ici, puis
|
Puis
|
||||||
- `python reading_annotations.py --update-score Interro`
|
- `python reading_annotations.py --update-score Interro`
|
||||||
- `python reading_grouped_annotations.py --update-score Interro`
|
- `python reading_grouped_annotations.py --update-score Interro`
|
||||||
pour mettre à jour les scores dans les images.
|
pour mettre à jour les scores dans les images.
|
||||||
@@ -309,6 +359,7 @@ OU
|
|||||||
+ update the copies from =miqmacs.fr/admin=.
|
+ update the copies from =miqmacs.fr/admin=.
|
||||||
6. (gestion perso) Impression d'une copie. Via Evince » print to pdf.
|
6. (gestion perso) Impression d'une copie. Via Evince » print to pdf.
|
||||||
|
|
||||||
|
* Autres
|
||||||
** Recorrection d'une seule copie (peu testé)
|
** Recorrection d'une seule copie (peu testé)
|
||||||
|
|
||||||
!! Attention, refaire ne marchera pas si tu fais une annotation non
|
!! Attention, refaire ne marchera pas si tu fais une annotation non
|
||||||
|
|||||||
+27
-18
@@ -1,16 +1,29 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import math
|
import math
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from config import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
|
||||||
|
|
||||||
# Configuration constants
|
# Configuration constants
|
||||||
ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/simple_eval.ods"
|
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
|
||||||
OUTPUT_DIR = Path("/home/sebastien/Rust/Server/copies")
|
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
|
||||||
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # Standard Linux font path
|
|
||||||
|
|
||||||
|
def score_font(size):
|
||||||
|
candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"]
|
||||||
|
for candidate in candidates:
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(str(candidate), size)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
def get_rounded_score(score):
|
def get_rounded_score(score):
|
||||||
"""Round score to one decimal place below (floor)."""
|
"""Round score to one decimal place below (floor)."""
|
||||||
@@ -20,7 +33,7 @@ def get_rounded_score(score):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def process_images(base_dir):
|
def process_images(base_dir, output_dir):
|
||||||
# 1. Load Data
|
# 1. Load Data
|
||||||
try:
|
try:
|
||||||
# header=None assumes the file starts directly with data.
|
# header=None assumes the file starts directly with data.
|
||||||
@@ -33,7 +46,7 @@ def process_images(base_dir):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# 2. Prepare Output Directory
|
# 2. Prepare Output Directory
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 3. Iterate Files
|
# 3. Iterate Files
|
||||||
# Structure: Dir/A Rendre/{name}/{name}.jpg
|
# Structure: Dir/A Rendre/{name}/{name}.jpg
|
||||||
@@ -68,12 +81,7 @@ def process_images(base_dir):
|
|||||||
# Dynamic font size (15% of image height)
|
# Dynamic font size (15% of image height)
|
||||||
font_size = int(width * 0.08)
|
font_size = int(width * 0.08)
|
||||||
|
|
||||||
try:
|
font = score_font(font_size)
|
||||||
font = ImageFont.truetype(FONT_PATH, font_size)
|
|
||||||
except IOError:
|
|
||||||
# Fallback if specific font not found
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
print(f"Warning: System font not found, using default for {student_name}")
|
|
||||||
|
|
||||||
text = str(score)
|
text = str(score)
|
||||||
|
|
||||||
@@ -90,7 +98,7 @@ def process_images(base_dir):
|
|||||||
draw.text((x, y), text, fill=(255, 0, 0), font=font)
|
draw.text((x, y), text, fill=(255, 0, 0), font=font)
|
||||||
|
|
||||||
# Save
|
# Save
|
||||||
save_path = OUTPUT_DIR / f"{student_name}.jpg"
|
save_path = output_dir / f"{student_name}.jpg"
|
||||||
img.save(save_path)
|
img.save(save_path)
|
||||||
print(f"Processed: {student_name} -> {score}")
|
print(f"Processed: {student_name} -> {score}")
|
||||||
|
|
||||||
@@ -99,7 +107,7 @@ def process_images(base_dir):
|
|||||||
|
|
||||||
for pdf_path in sorted(search_path.glob("*/*.pdf")):
|
for pdf_path in sorted(search_path.glob("*/*.pdf")):
|
||||||
student_name = pdf_path.stem # Filename without extension
|
student_name = pdf_path.stem # Filename without extension
|
||||||
save_path = OUTPUT_DIR / f"{student_name}.pdf"
|
save_path = output_dir / f"{student_name}.pdf"
|
||||||
|
|
||||||
shutil.copy(str(pdf_path), str(save_path))
|
shutil.copy(str(pdf_path), str(save_path))
|
||||||
|
|
||||||
@@ -111,6 +119,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
OUTPUT_DIR = OUTPUT_DIR / args.dir
|
base_dir = args.dir.expanduser().resolve()
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
output_dir = OUTPUT_DIR / base_dir.name
|
||||||
process_images(args.dir)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
process_images(base_dir, output_dir)
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
root_dir = args.input_path
|
root_dir = args.input_path
|
||||||
|
validated_labels = utils.read_all_labels(root_dir)
|
||||||
results = annotating.make_dictionary(root_dir)
|
results = annotating.make_dictionary(root_dir)
|
||||||
label_groups = os.path.join(root_dir, "label_groups")
|
label_groups = os.path.join(root_dir, "label_groups")
|
||||||
all_labels = os.path.join(root_dir, "labels")
|
all_labels = os.path.join(root_dir, "labels")
|
||||||
@@ -113,8 +114,7 @@ def main():
|
|||||||
print(f"Error: {all_labels} not found.")
|
print(f"Error: {all_labels} not found.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
with open(all_labels, 'r') as f:
|
lines = validated_labels
|
||||||
lines = [l.strip() for l in f if l.strip()]
|
|
||||||
|
|
||||||
groups = {}
|
groups = {}
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ matplotlib.use('Agg')
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import annotating
|
import annotating
|
||||||
|
import utils
|
||||||
from annotating import MARGIN_LEFT, ANNOT_WIDTH
|
from annotating import MARGIN_LEFT, ANNOT_WIDTH
|
||||||
|
|
||||||
# Global lock for Matplotlib/Latex rendering to prevent race conditions
|
# Global lock for Matplotlib/Latex rendering to prevent race conditions
|
||||||
@@ -230,6 +231,9 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
root_dir = input_path
|
root_dir = input_path
|
||||||
|
|
||||||
|
if os.path.exists(os.path.join(root_dir, "labels")):
|
||||||
|
utils.read_all_labels(root_dir)
|
||||||
|
|
||||||
if not args.refaire:
|
if not args.refaire:
|
||||||
results = annotating.make_dictionary(root_dir)
|
results = annotating.make_dictionary(root_dir)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from pathlib import Path
|
|||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from platform_utils import WindowsLabelError, validate_windows_labels
|
||||||
|
|
||||||
|
from .diagnostics import collect_diagnostics
|
||||||
from .runner import ProcessRunner
|
from .runner import ProcessRunner
|
||||||
from .state import StateStore
|
from .state import StateStore
|
||||||
from .workflow import (
|
from .workflow import (
|
||||||
@@ -42,6 +45,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.repository = repository.resolve()
|
self.repository = repository.resolve()
|
||||||
|
self.show_personal_steps = show_personal_steps
|
||||||
self.steps = build_workflow(show_personal_steps)
|
self.steps = build_workflow(show_personal_steps)
|
||||||
self.step_by_id = {step.id: step for step in self.steps}
|
self.step_by_id = {step.id: step for step in self.steps}
|
||||||
self.state_store = StateStore()
|
self.state_store = StateStore()
|
||||||
@@ -93,6 +97,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
path_entry.bind("<Return>", lambda _event: self._load_evaluation())
|
path_entry.bind("<Return>", lambda _event: self._load_evaluation())
|
||||||
ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid(row=0, column=2, padx=6)
|
ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid(row=0, column=2, padx=6)
|
||||||
ttk.Button(top, text="Charger", command=self._load_evaluation).grid(row=0, column=3)
|
ttk.Button(top, text="Charger", command=self._load_evaluation).grid(row=0, column=3)
|
||||||
|
ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=4, padx=(6, 0))
|
||||||
|
|
||||||
ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0))
|
ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0))
|
||||||
ttk.Entry(top, textvariable=self.api_key_var, show="•", width=32).grid(
|
ttk.Entry(top, textvariable=self.api_key_var, show="•", width=32).grid(
|
||||||
@@ -194,6 +199,47 @@ class CopienatorApp(tk.Tk):
|
|||||||
status = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3))
|
status = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3))
|
||||||
status.grid(row=3, column=0, sticky="ew")
|
status.grid(row=3, column=0, sticky="ew")
|
||||||
|
|
||||||
|
def _show_diagnostics(self) -> None:
|
||||||
|
checks = collect_diagnostics(
|
||||||
|
self.show_personal_steps, self.api_key_var.get(), self.evaluation
|
||||||
|
)
|
||||||
|
dialog = tk.Toplevel(self)
|
||||||
|
dialog.title("Diagnostic Copienator")
|
||||||
|
dialog.geometry("760x480")
|
||||||
|
dialog.transient(self)
|
||||||
|
dialog.columnconfigure(0, weight=1)
|
||||||
|
dialog.rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
required_missing = sum(1 for check in checks if check.required and not check.ok)
|
||||||
|
summary = (
|
||||||
|
"Tous les prérequis obligatoires sont disponibles."
|
||||||
|
if required_missing == 0
|
||||||
|
else f"{required_missing} prérequis obligatoire(s) manquant(s)."
|
||||||
|
)
|
||||||
|
ttk.Label(dialog, text=summary, padding=10, font=("TkDefaultFont", 11, "bold")).grid(
|
||||||
|
row=0, column=0, sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
tree = ttk.Treeview(dialog, columns=("state", "need", "detail"), show="headings")
|
||||||
|
tree.heading("state", text="État")
|
||||||
|
tree.heading("need", text="Niveau")
|
||||||
|
tree.heading("detail", text="Composant et détail")
|
||||||
|
tree.column("state", width=85, anchor="center")
|
||||||
|
tree.column("need", width=100, anchor="center")
|
||||||
|
tree.column("detail", width=530)
|
||||||
|
for check in checks:
|
||||||
|
tree.insert(
|
||||||
|
"",
|
||||||
|
"end",
|
||||||
|
values=(
|
||||||
|
"OK" if check.ok else "Manquant",
|
||||||
|
"Obligatoire" if check.required else "Optionnel",
|
||||||
|
f"{check.name} — {check.detail}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tree.grid(row=1, column=0, sticky="nsew", padx=10)
|
||||||
|
ttk.Button(dialog, text="Fermer", command=dialog.destroy).grid(row=2, column=0, pady=10)
|
||||||
|
|
||||||
def _browse_evaluation(self) -> None:
|
def _browse_evaluation(self) -> None:
|
||||||
selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository)
|
selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository)
|
||||||
if selected:
|
if selected:
|
||||||
@@ -482,6 +528,19 @@ class CopienatorApp(tk.Tk):
|
|||||||
if step.is_manual:
|
if step.is_manual:
|
||||||
self._mark_step("success")
|
self._mark_step("success")
|
||||||
return
|
return
|
||||||
|
if os.name == "nt" and step.id != "statement":
|
||||||
|
labels_path = evaluation / "labels"
|
||||||
|
if labels_path.is_file():
|
||||||
|
labels = [
|
||||||
|
line.strip()
|
||||||
|
for line in labels_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
validate_windows_labels(labels)
|
||||||
|
except WindowsLabelError as exc:
|
||||||
|
messagebox.showerror("Labels incompatibles avec Windows", str(exc))
|
||||||
|
return
|
||||||
if not self._validate_arguments():
|
if not self._validate_arguments():
|
||||||
return
|
return
|
||||||
missing = self._missing_requirements(step)
|
missing = self._missing_requirements(step)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platform_utils import windows_filename_problems
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DiagnosticCheck:
|
||||||
|
name: str
|
||||||
|
ok: bool
|
||||||
|
detail: str
|
||||||
|
required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def module_available(module: str) -> bool:
|
||||||
|
try:
|
||||||
|
return importlib.util.find_spec(module) is not None
|
||||||
|
except (ImportError, ModuleNotFoundError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def collect_diagnostics(
|
||||||
|
show_personal_steps: bool,
|
||||||
|
api_key_override: str = "",
|
||||||
|
evaluation: Path | None = None,
|
||||||
|
) -> list[DiagnosticCheck]:
|
||||||
|
checks = [
|
||||||
|
DiagnosticCheck(
|
||||||
|
"Système",
|
||||||
|
os.name == "nt" or sys.platform.startswith("linux"),
|
||||||
|
f"{sys.platform} — Linux et Windows sont pris en charge",
|
||||||
|
),
|
||||||
|
DiagnosticCheck("Python", True, sys.executable),
|
||||||
|
]
|
||||||
|
|
||||||
|
modules = {
|
||||||
|
"numpy": "numpy",
|
||||||
|
"pandas": "pandas",
|
||||||
|
"matplotlib": "matplotlib",
|
||||||
|
"Pillow": "PIL",
|
||||||
|
"pydantic": "pydantic",
|
||||||
|
"pypdf": "pypdf",
|
||||||
|
"pdf2image": "pdf2image",
|
||||||
|
"reportlab": "reportlab",
|
||||||
|
"img2pdf": "img2pdf",
|
||||||
|
"PyMuPDF": "fitz",
|
||||||
|
"ftfy": "ftfy",
|
||||||
|
"ezodf": "ezodf",
|
||||||
|
"Google GenAI": "google.genai",
|
||||||
|
}
|
||||||
|
checks.extend(
|
||||||
|
DiagnosticCheck(label, module_available(module), f"Module Python : {module}")
|
||||||
|
for label, module in modules.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
programs = (
|
||||||
|
("Poppler", ("pdftoppm", "pdftocairo"), True),
|
||||||
|
("LaTeX", ("pdflatex",), True),
|
||||||
|
("PDF Arranger", ("pdf-arranger", "pdfarranger"), False),
|
||||||
|
)
|
||||||
|
for label, candidates, required in programs:
|
||||||
|
executable = next((shutil.which(candidate) for candidate in candidates if shutil.which(candidate)), None)
|
||||||
|
detail = executable or "Introuvable dans PATH"
|
||||||
|
checks.append(DiagnosticCheck(label, executable is not None, detail, required))
|
||||||
|
|
||||||
|
api_key = api_key_override.strip() or os.environ.get("GEMINI_API_KEY")
|
||||||
|
checks.append(
|
||||||
|
DiagnosticCheck(
|
||||||
|
"Clé Gemini",
|
||||||
|
bool(api_key),
|
||||||
|
"GEMINI_API_KEY est définie" if api_key else "À saisir dans le GUI ou dans l’environnement",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if evaluation and (evaluation / "labels").is_file():
|
||||||
|
labels = [
|
||||||
|
line.strip()
|
||||||
|
for line in (evaluation / "labels").read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
unsafe = [label for label in labels if windows_filename_problems(label)]
|
||||||
|
detail = (
|
||||||
|
"Tous les labels sont utilisables comme noms de fichiers"
|
||||||
|
if not unsafe
|
||||||
|
else "Caractères Windows interdits : " + ", ".join(unsafe[:3])
|
||||||
|
)
|
||||||
|
if len(unsafe) > 3:
|
||||||
|
detail += f" (+{len(unsafe) - 3})"
|
||||||
|
checks.append(
|
||||||
|
DiagnosticCheck(
|
||||||
|
"Labels compatibles Windows",
|
||||||
|
not unsafe,
|
||||||
|
detail,
|
||||||
|
os.name == "nt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if show_personal_steps:
|
||||||
|
checks.append(
|
||||||
|
DiagnosticCheck(
|
||||||
|
"gestion_classe",
|
||||||
|
shutil.which("gestion_classe") is not None,
|
||||||
|
shutil.which("gestion_classe") or "Introuvable dans PATH",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from config import CURRENT_SCORE_ODS_PATH, FINAL_SCORE_ODS_PATH
|
||||||
|
|
||||||
|
for label, configured_path in (
|
||||||
|
("ODS courant", CURRENT_SCORE_ODS_PATH),
|
||||||
|
("ODS final", FINAL_SCORE_ODS_PATH),
|
||||||
|
):
|
||||||
|
path = Path(configured_path).expanduser()
|
||||||
|
checks.append(DiagnosticCheck(label, path.is_file(), str(path), False))
|
||||||
|
except ImportError:
|
||||||
|
checks.append(DiagnosticCheck("Configuration personnelle", False, "Variables absentes", False))
|
||||||
|
|
||||||
|
return checks
|
||||||
@@ -30,6 +30,7 @@ class CommandVariant:
|
|||||||
program: str | None
|
program: str | None
|
||||||
kind: str = "python" # python, shell, external, manual
|
kind: str = "python" # python, shell, external, manual
|
||||||
fixed_args: tuple[str, ...] = ()
|
fixed_args: tuple[str, ...] = ()
|
||||||
|
fixed_args_before_positionals: bool = False
|
||||||
dangerous: bool = False
|
dangerous: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +117,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Prétraitement des copies",
|
"Prétraitement des copies",
|
||||||
"Retourner toutes les copies",
|
"Retourner toutes les copies",
|
||||||
"Rotation facultative de 180° lorsque les scans sont à l’envers.",
|
"Rotation facultative de 180° lorsque les scans sont à l’envers.",
|
||||||
(CommandVariant("rotate", "Rotation", "rotate_all.sh", "shell"),),
|
(
|
||||||
|
CommandVariant(
|
||||||
|
"rotate",
|
||||||
|
"Rotation",
|
||||||
|
"copies_tools.py",
|
||||||
|
"python",
|
||||||
|
("rotate",),
|
||||||
|
fixed_args_before_positionals=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
@@ -125,7 +135,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Prétraitement des copies",
|
"Prétraitement des copies",
|
||||||
"Renommer les PDF en CopieXX.pdf",
|
"Renommer les PDF en CopieXX.pdf",
|
||||||
"Renomme les PDF du dossier d’évaluation dans leur ordre courant.",
|
"Renomme les PDF du dossier d’évaluation dans leur ordre courant.",
|
||||||
(CommandVariant("rename", "Renommage", "rename_to_copie.sh", "shell"),),
|
(
|
||||||
|
CommandVariant(
|
||||||
|
"rename",
|
||||||
|
"Renommage",
|
||||||
|
"copies_tools.py",
|
||||||
|
"python",
|
||||||
|
("rename",),
|
||||||
|
fixed_args_before_positionals=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
@@ -464,8 +483,11 @@ def build_command(
|
|||||||
elif spec.flag:
|
elif spec.flag:
|
||||||
options.extend((spec.flag, rendered))
|
options.extend((spec.flag, rendered))
|
||||||
|
|
||||||
|
if variant.fixed_args_before_positionals:
|
||||||
|
command.extend(variant.fixed_args)
|
||||||
command.extend(positionals)
|
command.extend(positionals)
|
||||||
command.extend(variant.fixed_args)
|
if not variant.fixed_args_before_positionals:
|
||||||
|
command.extend(variant.fixed_args)
|
||||||
command.extend(options)
|
command.extend(options)
|
||||||
if extra_arguments.strip():
|
if extra_arguments.strip():
|
||||||
command.extend(shlex.split(extra_arguments, posix=os.name != "nt"))
|
command.extend(shlex.split(extra_arguments, posix=os.name != "nt"))
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
|
|
||||||
|
def copy_pdfs(directory: Path) -> list[Path]:
|
||||||
|
directory = directory.expanduser().resolve()
|
||||||
|
if not directory.is_dir():
|
||||||
|
raise NotADirectoryError(f"Dossier introuvable : {directory}")
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
path
|
||||||
|
for path in directory.glob("*.pdf")
|
||||||
|
if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"}
|
||||||
|
),
|
||||||
|
key=lambda path: path.name.casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_pdf(path: Path) -> None:
|
||||||
|
temporary = path.with_name(f".{path.stem}.rotate-{uuid.uuid4().hex}.pdf")
|
||||||
|
try:
|
||||||
|
with path.open("rb") as source, temporary.open("wb") as destination:
|
||||||
|
reader = PdfReader(source)
|
||||||
|
writer = PdfWriter()
|
||||||
|
for page in reader.pages:
|
||||||
|
writer.add_page(page.rotate(180))
|
||||||
|
if reader.metadata:
|
||||||
|
metadata = {
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in reader.metadata.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
writer.add_metadata(metadata)
|
||||||
|
writer.write(destination)
|
||||||
|
temporary.replace(path)
|
||||||
|
finally:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_all(directory: Path) -> int:
|
||||||
|
files = copy_pdfs(directory)
|
||||||
|
for path in files:
|
||||||
|
rotate_pdf(path)
|
||||||
|
print(f"Rotated: {path.name}")
|
||||||
|
if not files:
|
||||||
|
print("No PDF copies found.")
|
||||||
|
return len(files)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_all(directory: Path) -> list[tuple[Path, Path]]:
|
||||||
|
directory = directory.expanduser().resolve()
|
||||||
|
files = copy_pdfs(directory)
|
||||||
|
width = max(2, len(str(len(files))))
|
||||||
|
plan = [
|
||||||
|
(source, directory / f"Copie{index:0{width}d}.pdf")
|
||||||
|
for index, source in enumerate(files, start=1)
|
||||||
|
]
|
||||||
|
staged: list[tuple[Path, Path, Path]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for source, destination in plan:
|
||||||
|
temporary = directory / f".copienator-rename-{uuid.uuid4().hex}.pdf"
|
||||||
|
source.replace(temporary)
|
||||||
|
staged.append((source, temporary, destination))
|
||||||
|
|
||||||
|
completed: list[tuple[Path, Path, Path]] = []
|
||||||
|
try:
|
||||||
|
for source, temporary, destination in staged:
|
||||||
|
temporary.replace(destination)
|
||||||
|
completed.append((source, temporary, destination))
|
||||||
|
print(f"Renamed: {source.name} -> {destination.name}")
|
||||||
|
except OSError:
|
||||||
|
for _source, temporary, destination in completed:
|
||||||
|
if destination.exists():
|
||||||
|
destination.replace(temporary)
|
||||||
|
raise
|
||||||
|
except OSError:
|
||||||
|
for source, temporary, _destination in staged:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.replace(source)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
print("No PDF copies found.")
|
||||||
|
return [(source, destination) for source, _temporary, destination in staged]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Prepare scanned PDF copies.")
|
||||||
|
subparsers = parser.add_subparsers(dest="operation", required=True)
|
||||||
|
for operation in ("rotate", "rename"):
|
||||||
|
subparser = subparsers.add_parser(operation)
|
||||||
|
subparser.add_argument("directory", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.operation == "rotate":
|
||||||
|
rotate_all(args.directory)
|
||||||
|
else:
|
||||||
|
rename_all(args.directory)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,6 +6,15 @@ API_KEY = os.environ.get("GEMINI_API_KEY")
|
|||||||
EXPORT_DIR = Path("Export")
|
EXPORT_DIR = Path("Export")
|
||||||
IMPORT_DIR = Path("Import")
|
IMPORT_DIR = Path("Import")
|
||||||
|
|
||||||
|
# Les étapes gestion_classe, ODS et publication sont masquées par défaut.
|
||||||
|
SHOW_PERSONAL_STEPS = False
|
||||||
|
|
||||||
|
# Chemins utilisés uniquement par les étapes personnelles.
|
||||||
|
CURRENT_SCORE_ODS_PATH = Path("current_eval.ods")
|
||||||
|
FINAL_SCORE_ODS_PATH = Path("simple_eval.ods")
|
||||||
|
FINAL_SCORE_OUTPUT_DIR = Path("Server") / "copies"
|
||||||
|
FINAL_SCORE_FONT_PATH = None
|
||||||
|
|
||||||
# Modèle pour des choses très légères
|
# Modèle pour des choses très légères
|
||||||
MODEL_LITE_ID = "gemini-3.5-flash-lite"
|
MODEL_LITE_ID = "gemini-3.5-flash-lite"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from platform_utils import WindowsLabelError, validate_windows_labels
|
||||||
from utils import compile_to_pdf
|
from utils import compile_to_pdf
|
||||||
|
|
||||||
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
|
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
|
||||||
@@ -191,6 +192,7 @@ def process_directory(directory):
|
|||||||
indexes = data.get('indexes', [])
|
indexes = data.get('indexes', [])
|
||||||
if not indexes:
|
if not indexes:
|
||||||
label = f"Ex {current_ex_num}"
|
label = f"Ex {current_ex_num}"
|
||||||
|
validate_windows_labels([label])
|
||||||
f_labels.write(f"{label}\n")
|
f_labels.write(f"{label}\n")
|
||||||
fetch_and_save_sub_text(ids, [], label, paths['Text2'])
|
fetch_and_save_sub_text(ids, [], label, paths['Text2'])
|
||||||
fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
|
fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
|
||||||
@@ -198,6 +200,7 @@ def process_directory(directory):
|
|||||||
for item in indexes:
|
for item in indexes:
|
||||||
suffix = format_indices(item['indices'], problem)
|
suffix = format_indices(item['indices'], problem)
|
||||||
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
|
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
|
||||||
|
validate_windows_labels([label])
|
||||||
f_labels.write(f"{label}\n")
|
f_labels.write(f"{label}\n")
|
||||||
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
|
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
|
||||||
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
|
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
|
||||||
@@ -243,6 +246,8 @@ def process_directory(directory):
|
|||||||
|
|
||||||
current_ex_num += 1
|
current_ex_num += 1
|
||||||
|
|
||||||
|
except WindowsLabelError:
|
||||||
|
raise
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
print(f"Error decoding JSON in block: {json_str}")
|
print(f"Error decoding JSON in block: {json_str}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,52 +1,36 @@
|
|||||||
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import EXPORT_DIR
|
from config import EXPORT_DIR
|
||||||
|
from platform_utils import replace_with_link_or_copy
|
||||||
|
|
||||||
def process_directory(dir_arg):
|
|
||||||
# Résolution des chemins
|
|
||||||
base_dir = Path(dir_arg)
|
|
||||||
bgnot_dir = base_dir / "BGnot"
|
|
||||||
sync_dir = EXPORT_DIR / dir_arg
|
|
||||||
|
|
||||||
# Création du dossier de destination s'il n'existe pas
|
def export_directory(base_dir, source_dir_name):
|
||||||
|
base_dir = Path(base_dir).expanduser().resolve()
|
||||||
|
source_dir = base_dir / source_dir_name
|
||||||
|
sync_dir = Path(EXPORT_DIR).expanduser() / base_dir.name
|
||||||
sync_dir.mkdir(parents=True, exist_ok=True)
|
sync_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if not bgnot_dir.is_dir():
|
if not source_dir.is_dir():
|
||||||
print(f"Erreur : le sous-dossier {bgnot_dir} n'existe pas.")
|
print(f"Erreur : le sous-dossier {source_dir} n'existe pas.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Récupération de tous les sous-dossiers
|
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
|
||||||
subdirs = [d for d in bgnot_dir.iterdir() if d.is_dir()]
|
if source_dir_name == "BGnot" and subdirs:
|
||||||
if not subdirs:
|
all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs)
|
||||||
return
|
if not all_start_with_copie:
|
||||||
|
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
|
||||||
|
|
||||||
# Application des règles de sélection
|
for subdir in subdirs:
|
||||||
all_start_with_copie = all(d.name.startswith("Copie") for d in subdirs)
|
|
||||||
if all_start_with_copie:
|
|
||||||
chosen_dirs = subdirs
|
|
||||||
else:
|
|
||||||
chosen_dirs = [d for d in subdirs if not d.name.startswith("Copie")]
|
|
||||||
|
|
||||||
# Traitement des dossiers sélectionnés
|
|
||||||
for subdir in chosen_dirs:
|
|
||||||
concat_file = subdir / "Concat.pdf"
|
concat_file = subdir / "Concat.pdf"
|
||||||
|
|
||||||
if not concat_file.is_file():
|
if not concat_file.is_file():
|
||||||
print(f"Attention : le fichier {concat_file} est introuvable.")
|
print(f"Attention : le fichier {concat_file} est introuvable.")
|
||||||
continue
|
continue
|
||||||
|
destination = sync_dir / f"{subdir.name}.pdf"
|
||||||
|
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
|
||||||
|
print(f"Exported: {destination} ({method})")
|
||||||
|
|
||||||
symlink_path = sync_dir / f"{subdir.name}.pdf"
|
|
||||||
|
|
||||||
# Supprime le lien précédent s'il existe pour éviter une erreur
|
|
||||||
if symlink_path.is_symlink() or symlink_path.exists():
|
|
||||||
symlink_path.unlink()
|
|
||||||
|
|
||||||
# Création du lien symbolique (pointe vers le chemin absolu pour éviter les problèmes)
|
|
||||||
os.link(concat_file.absolute(), symlink_path)
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Move to tablette folder.")
|
parser = argparse.ArgumentParser(description="Move to tablette folder.")
|
||||||
@@ -54,21 +38,7 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
|
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root_dir = args.dir
|
|
||||||
|
|
||||||
if args.refaire:
|
if args.refaire:
|
||||||
base_dir = Path(root_dir)
|
export_directory(args.dir, "BRnot")
|
||||||
brnot_dir = base_dir / "BRnot"
|
|
||||||
sync_dir = EXPORT_DIR / root_dir
|
|
||||||
sync_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for f in brnot_dir.iterdir():
|
|
||||||
concat_file = f / "Concat.pdf"
|
|
||||||
if f.is_dir() and concat_file.is_file():
|
|
||||||
symlink_path = sync_dir / f"{f.name}.pdf"
|
|
||||||
if symlink_path.exists():
|
|
||||||
symlink_path.unlink()
|
|
||||||
os.link(concat_file.absolute(), symlink_path)
|
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
export_directory(args.dir, "BGnot")
|
||||||
process_directory(root_dir)
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import List, Union
|
|||||||
from google import genai
|
from google import genai
|
||||||
from google.genai import types
|
from google.genai import types
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from platform_utils import validate_windows_labels
|
||||||
from utils import compile_to_pdf
|
from utils import compile_to_pdf
|
||||||
|
|
||||||
def get_lcp(s1: str, s2: str) -> str:
|
def get_lcp(s1: str, s2: str) -> str:
|
||||||
@@ -602,6 +603,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
|||||||
break
|
break
|
||||||
|
|
||||||
labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)]
|
labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)]
|
||||||
|
validate_windows_labels(labels_list)
|
||||||
|
|
||||||
# Save labels and proceed
|
# Save labels and proceed
|
||||||
with open(folder / "labels", 'w', encoding='utf-8') as f_labels:
|
with open(folder / "labels", 'w', encoding='utf-8') as f_labels:
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ args = parser.parse_args()
|
|||||||
# input_arg = Path(args.input_path)
|
# input_arg = Path(args.input_path)
|
||||||
image_files = []
|
image_files = []
|
||||||
|
|
||||||
from utils import natural_key
|
from utils import natural_key, read_all_labels
|
||||||
|
|
||||||
for path_str in args.input_paths:
|
for path_str in args.input_paths:
|
||||||
input_arg = Path(path_str)
|
input_arg = Path(path_str)
|
||||||
@@ -222,8 +222,9 @@ for path_str in args.input_paths:
|
|||||||
else:
|
else:
|
||||||
print(f"Warning: No variants found for {target_file.stem} in {CUTLEFT_DIR}")
|
print(f"Warning: No variants found for {target_file.stem} in {CUTLEFT_DIR}")
|
||||||
|
|
||||||
labels_txt = (INPUT_DIR / "labels").read_text()
|
all_labels = read_all_labels(INPUT_DIR)
|
||||||
valid_labels_set = set(line.strip() for line in labels_txt.splitlines() if line.strip())
|
labels_txt = "\n".join(all_labels) + "\n"
|
||||||
|
valid_labels_set = set(all_labels)
|
||||||
names_path = (INPUT_DIR / "names")
|
names_path = (INPUT_DIR / "names")
|
||||||
if not os.path.exists(names_path):
|
if not os.path.exists(names_path):
|
||||||
names_path = Path("names")
|
names_path = Path("names")
|
||||||
|
|||||||
+10
-7
@@ -1,10 +1,11 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from collections import defaultdict
|
from platform_utils import replace_with_link_or_copy, safe_filename
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
@@ -54,7 +55,7 @@ def main():
|
|||||||
elif len(ids) > 1:
|
elif len(ids) > 1:
|
||||||
print(f"ALERT: Name '{name}' assigned to multiple IDs: {', '.join(ids)}")
|
print(f"ALERT: Name '{name}' assigned to multiple IDs: {', '.join(ids)}")
|
||||||
|
|
||||||
safe_name = re.sub(r'[<>:"/\\|?*]', '', name).strip()
|
safe_name = safe_filename(name)
|
||||||
|
|
||||||
for copie_id in ids:
|
for copie_id in ids:
|
||||||
path_b = os.path.join(work_dir, f"{bnot_dir}/Copie{copie_id}")
|
path_b = os.path.join(work_dir, f"{bnot_dir}/Copie{copie_id}")
|
||||||
@@ -81,9 +82,11 @@ def main():
|
|||||||
src_file = os.path.join(source_folder, src_name)
|
src_file = os.path.join(source_folder, src_name)
|
||||||
dst_link = os.path.join(dest_path, dst_name)
|
dst_link = os.path.join(dest_path, dst_name)
|
||||||
try:
|
try:
|
||||||
if os.path.lexists(dst_link): os.remove(dst_link)
|
if os.path.exists(src_file):
|
||||||
if os.path.exists(src_file): os.symlink(src_file, dst_link)
|
method = replace_with_link_or_copy(src_file, dst_link, prefer="symlink")
|
||||||
except Exception as e:
|
if method == "copy":
|
||||||
|
print(f"Copied {src_name} for {dest_folder_name} (links unavailable)")
|
||||||
|
except OSError as e:
|
||||||
print(f"Error linking {src_name} for {dest_folder_name}: {e}")
|
print(f"Error linking {src_name} for {dest_folder_name}: {e}")
|
||||||
|
|
||||||
# --- 4. Print Unassigned Names ---
|
# --- 4. Print Unassigned Names ---
|
||||||
|
|||||||
+12
-8
@@ -1,16 +1,17 @@
|
|||||||
import fitz # PyMuPDF
|
import glob
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox
|
|
||||||
from PIL import Image, ImageTk, ImageDraw
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import glob
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import sys
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
from PIL import Image, ImageDraw, ImageTk
|
||||||
from pypdf import PdfReader, PdfWriter
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
from config import PAGE_SPLITTER_KB
|
from config import PAGE_SPLITTER_KB
|
||||||
|
from platform_utils import launch_pdf_arranger
|
||||||
|
|
||||||
|
|
||||||
# --- Constants ---
|
# --- Constants ---
|
||||||
@@ -169,7 +170,10 @@ class PDFPreviewer:
|
|||||||
self.current_zoom = 1.0
|
self.current_zoom = 1.0
|
||||||
|
|
||||||
def start_arranger(self):
|
def start_arranger(self):
|
||||||
subprocess.Popen(["pdf-arranger", self.pdf_path])
|
try:
|
||||||
|
launch_pdf_arranger(self.pdf_path)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
messagebox.showerror("PDF Arranger", str(exc))
|
||||||
|
|
||||||
def on_resize(self, event):
|
def on_resize(self, event):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
WINDOWS_RESERVED_NAMES = {
|
||||||
|
"CON",
|
||||||
|
"PRN",
|
||||||
|
"AUX",
|
||||||
|
"NUL",
|
||||||
|
*(f"COM{number}" for number in range(1, 10)),
|
||||||
|
*(f"LPT{number}" for number in range(1, 10)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsLabelError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def windows_filename_problems(value: str) -> list[str]:
|
||||||
|
problems = []
|
||||||
|
forbidden = sorted({character for character in value if character in '<>:"/\\|?*'})
|
||||||
|
if forbidden:
|
||||||
|
problems.append("caractères interdits " + " ".join(repr(char) for char in forbidden))
|
||||||
|
if any(ord(character) < 32 for character in value):
|
||||||
|
problems.append("caractère de contrôle")
|
||||||
|
if value.endswith((" ", ".")):
|
||||||
|
problems.append("espace ou point final")
|
||||||
|
if not value or value in {".", ".."}:
|
||||||
|
problems.append("nom vide ou réservé")
|
||||||
|
elif value.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES:
|
||||||
|
problems.append("nom système réservé")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def validate_windows_labels(
|
||||||
|
labels: list[str] | tuple[str, ...], platform_name: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Reject labels that cannot be used as filenames on native Windows."""
|
||||||
|
if (platform_name or os.name) != "nt":
|
||||||
|
return
|
||||||
|
invalid = []
|
||||||
|
for label in labels:
|
||||||
|
problems = windows_filename_problems(label)
|
||||||
|
if problems:
|
||||||
|
invalid.append((label, problems))
|
||||||
|
if not invalid:
|
||||||
|
return
|
||||||
|
details = "\n".join(
|
||||||
|
f" - {label!r}: {', '.join(problems)}" for label, problems in invalid
|
||||||
|
)
|
||||||
|
raise WindowsLabelError(
|
||||||
|
"Labels incompatibles avec Windows :\n"
|
||||||
|
f"{details}\n"
|
||||||
|
"Modifiez ces labels avant de poursuivre (par exemple, remplacez ':' par ' - ')."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(value: str, fallback: str = "Unknown") -> str:
|
||||||
|
"""Return a filename component accepted by both Linux and Windows."""
|
||||||
|
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", value).strip().rstrip(". ")
|
||||||
|
if not cleaned:
|
||||||
|
cleaned = fallback
|
||||||
|
if cleaned.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES:
|
||||||
|
cleaned = f"_{cleaned}"
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def open_path(path: str | Path) -> None:
|
||||||
|
"""Open a file with the desktop's default application."""
|
||||||
|
target = str(Path(path).expanduser().resolve())
|
||||||
|
if os.name == "nt":
|
||||||
|
os.startfile(target) # type: ignore[attr-defined]
|
||||||
|
elif sys.platform.startswith("linux"):
|
||||||
|
subprocess.Popen(["xdg-open", target])
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||||
|
|
||||||
|
|
||||||
|
def launch_pdf_arranger(path: str | Path) -> None:
|
||||||
|
executable = shutil.which("pdf-arranger") or shutil.which("pdfarranger")
|
||||||
|
if not executable:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"PDF Arranger is not installed or is not available in PATH."
|
||||||
|
)
|
||||||
|
subprocess.Popen([executable, str(Path(path).expanduser().resolve())])
|
||||||
|
|
||||||
|
|
||||||
|
def replace_with_link_or_copy(
|
||||||
|
source: str | Path,
|
||||||
|
destination: str | Path,
|
||||||
|
*,
|
||||||
|
prefer: Literal["hardlink", "symlink"] = "hardlink",
|
||||||
|
) -> str:
|
||||||
|
"""Replace destination with a link, falling back to a regular copy."""
|
||||||
|
source_path = Path(source).expanduser().resolve()
|
||||||
|
destination_path = Path(destination).expanduser()
|
||||||
|
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if os.path.lexists(destination_path):
|
||||||
|
destination_path.unlink()
|
||||||
|
|
||||||
|
strategies = (prefer, "symlink" if prefer == "hardlink" else "hardlink")
|
||||||
|
for strategy in strategies:
|
||||||
|
try:
|
||||||
|
if strategy == "hardlink":
|
||||||
|
os.link(source_path, destination_path)
|
||||||
|
else:
|
||||||
|
os.symlink(source_path, destination_path)
|
||||||
|
return strategy
|
||||||
|
except (NotImplementedError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
shutil.copy2(source_path, destination_path)
|
||||||
|
return "copy"
|
||||||
+17
-16
@@ -1,14 +1,15 @@
|
|||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
import threading
|
|
||||||
import re
|
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
import sys
|
||||||
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import messagebox
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageTk
|
from PIL import Image, ImageDraw, ImageFont, ImageTk
|
||||||
|
|
||||||
|
from platform_utils import open_path
|
||||||
|
|
||||||
print("o to open pdf, O original pdf, e to emacs part, p to go back, i to interro, click for coordinates")
|
print("o to open pdf, O original pdf, e to emacs part, p to go back, i to interro, click for coordinates")
|
||||||
|
|
||||||
# --- Configuration & Globals ---
|
# --- Configuration & Globals ---
|
||||||
@@ -336,7 +337,7 @@ class ImageViewer:
|
|||||||
a = self.current_json_path.stem.split('_')[0] + ".pdf"
|
a = self.current_json_path.stem.split('_')[0] + ".pdf"
|
||||||
pdf_path = self.current_json_path.with_name(a)
|
pdf_path = self.current_json_path.with_name(a)
|
||||||
print(f"Opening {pdf_path}")
|
print(f"Opening {pdf_path}")
|
||||||
subprocess.Popen(['xdg-open', str(pdf_path.absolute())])
|
open_path(pdf_path)
|
||||||
|
|
||||||
def on_open_interro(self, event):
|
def on_open_interro(self, event):
|
||||||
if self.is_viewing and self.current_json_path:
|
if self.is_viewing and self.current_json_path:
|
||||||
@@ -347,25 +348,28 @@ class ImageViewer:
|
|||||||
if local_accent.exists():
|
if local_accent.exists():
|
||||||
pdf_path = str(local_accent)
|
pdf_path = str(local_accent)
|
||||||
elif local_plain.exists():
|
elif local_plain.exists():
|
||||||
pdf_path = str(local_plain)
|
pdf_path = local_plain
|
||||||
else:
|
else:
|
||||||
# Fallback to the Interro staging directory
|
messagebox.showerror(
|
||||||
pdf_path = f"/home/sebastien/Prépa/Staging/Interro/{self.base_dir.name}.pdf"
|
"PDF not found",
|
||||||
|
f"Neither {local_accent.name} nor {local_plain.name} exists in {self.base_dir}.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
print(f"Opening {pdf_path}")
|
print(f"Opening {pdf_path}")
|
||||||
subprocess.Popen(['xdg-open', pdf_path])
|
open_path(pdf_path)
|
||||||
|
|
||||||
def on_open_ori_pdf(self, event):
|
def on_open_ori_pdf(self, event):
|
||||||
if self.is_viewing and self.current_json_path:
|
if self.is_viewing and self.current_json_path:
|
||||||
new_filename = self.current_json_path.stem.split('_')[0] + ".pdf"
|
new_filename = self.current_json_path.stem.split('_')[0] + ".pdf"
|
||||||
pdf_path = self.base_dir / "Copies Originales" / new_filename
|
pdf_path = self.base_dir / "Copies Originales" / new_filename
|
||||||
print(f"Opening {pdf_path}")
|
print(f"Opening {pdf_path}")
|
||||||
subprocess.Popen(['xdg-open', str(pdf_path.absolute())])
|
open_path(pdf_path)
|
||||||
|
|
||||||
def on_edit(self, event):
|
def on_edit(self, event):
|
||||||
if self.is_viewing and self.current_json_path:
|
if self.is_viewing and self.current_json_path:
|
||||||
print(f"Opening {self.current_json_path}")
|
print(f"Opening {self.current_json_path}")
|
||||||
subprocess.Popen(['xdg-open', str(self.current_json_path.absolute())])
|
open_path(self.current_json_path)
|
||||||
|
|
||||||
def on_click(self, event):
|
def on_click(self, event):
|
||||||
if not self.is_viewing: return
|
if not self.is_viewing: return
|
||||||
@@ -418,14 +422,11 @@ if __name__ == "__main__":
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
files_to_process = sorted(cutleft_dir.glob("*.jpg"))
|
files_to_process = sorted(cutleft_dir.glob("*.jpg"))
|
||||||
|
|
||||||
labels_txt = (base_dir / "labels").read_text()
|
|
||||||
valid_labels_set = set(line.strip() for line in labels_txt.splitlines() if line.strip())
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
all_labels = read_all_labels(base_dir)
|
all_labels = read_all_labels(base_dir)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
all_labels = []
|
all_labels = []
|
||||||
|
valid_labels_set = set(all_labels)
|
||||||
|
|
||||||
t = threading.Thread(target=worker_thread, args=(base_dir, files_to_process, all_labels))
|
t = threading.Thread(target=worker_thread, args=(base_dir, files_to_process, all_labels))
|
||||||
t.daemon = True
|
t.daemon = True
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import shutil
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from utils import read_all_labels
|
||||||
|
|
||||||
carreau = 1000 // 38
|
carreau = 1000 // 38
|
||||||
|
|
||||||
|
|
||||||
@@ -208,6 +210,8 @@ if __name__ == "__main__":
|
|||||||
print(f"Error: {input_arg} is not a valid file or directory.")
|
print(f"Error: {input_arg} is not a valid file or directory.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
read_all_labels(base_dir)
|
||||||
|
|
||||||
for pdf_path in pdf_files:
|
for pdf_path in pdf_files:
|
||||||
json_path = pdf_path.with_suffix(".json")
|
json_path = pdf_path.with_suffix(".json")
|
||||||
# print("Debug :", json_path)
|
# print("Debug :", json_path)
|
||||||
|
|||||||
+7
-6
@@ -1,15 +1,16 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
|
||||||
import ezodf
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from utils import natural_key, read_all_labels
|
import ezodf
|
||||||
|
|
||||||
|
from config import CURRENT_SCORE_ODS_PATH
|
||||||
|
from utils import read_all_labels
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/current_eval.ods"
|
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
|
||||||
TARGET_DIR_NAME = "A Rendre"
|
TARGET_DIR_NAME = "A Rendre"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -34,7 +35,7 @@ def main():
|
|||||||
|
|
||||||
print(f"Opening ODS file: {ODS_PATH}...")
|
print(f"Opening ODS file: {ODS_PATH}...")
|
||||||
try:
|
try:
|
||||||
doc = ezodf.opendoc(ODS_PATH)
|
doc = ezodf.opendoc(str(ODS_PATH))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to open ODS: {e}")
|
print(f"Failed to open ODS: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platform_utils import validate_windows_labels
|
||||||
|
|
||||||
def natural_key(text):
|
def natural_key(text):
|
||||||
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(text))]
|
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(text))]
|
||||||
|
|
||||||
def read_all_labels(base_dir):
|
def read_all_labels(base_dir):
|
||||||
return list(filter(None, (Path(base_dir) / "labels").read_text().splitlines()))
|
labels = list(filter(None, (Path(base_dir) / "labels").read_text().splitlines()))
|
||||||
|
validate_windows_labels(labels)
|
||||||
|
return labels
|
||||||
|
|
||||||
def enonce_total(base_dir):
|
def enonce_total(base_dir):
|
||||||
text_dir = Path(base_dir) / 'Text'
|
text_dir = Path(base_dir) / 'Text'
|
||||||
@@ -25,18 +29,15 @@ def enonce_total(base_dir):
|
|||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
|
from platform_utils import open_path
|
||||||
|
|
||||||
def edit_file_and_enter(file):
|
def edit_file_and_enter(file):
|
||||||
editor = os.environ.get("EDITOR")
|
editor = os.environ.get("EDITOR")
|
||||||
try:
|
try:
|
||||||
if editor:
|
if editor:
|
||||||
subprocess.run(shlex.split(editor) + [str(file)])
|
subprocess.run(shlex.split(editor, posix=os.name != "nt") + [str(file)])
|
||||||
else:
|
else:
|
||||||
if sys.platform.startswith("linux"):
|
open_path(file)
|
||||||
subprocess.run(["xdg-open", str(file)])
|
|
||||||
elif sys.platform == "darwin":
|
|
||||||
subprocess.run(["open", str(file)])
|
|
||||||
else:
|
|
||||||
os.startfile(str(file))
|
|
||||||
input("Press ENTER here once you have saved and closed the text file...")
|
input("Press ENTER here once you have saved and closed the text file...")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error running editor: {e}")
|
print(f"Error running editor: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user