785 lines
No EOL
34 KiB
Python
785 lines
No EOL
34 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Convertisseur QCM HTML vers LaTeX pour auto-multiple-choice
|
|
Gère les extraits de code Python avec minted
|
|
"""
|
|
|
|
import re
|
|
import os
|
|
from bs4 import BeautifulSoup
|
|
from pathlib import Path
|
|
import html
|
|
|
|
class QCMConverter:
|
|
def __init__(self, output_dir="codes", debug=False, layout_threshold=50, force_layout=None, use_multicol=False, multicol_columns=2, use_csv=False, element_group="general"):
|
|
self.output_dir = Path(output_dir)
|
|
self.output_dir.mkdir(exist_ok=True)
|
|
self.code_counter = 0
|
|
self.debug = debug
|
|
self.layout_threshold = layout_threshold
|
|
self.force_layout = force_layout # 'horizontal', 'vertical', ou None
|
|
self.use_multicol = use_multicol
|
|
self.multicol_columns = multicol_columns
|
|
self.use_csv = use_csv
|
|
self.element_group = element_group
|
|
|
|
def escape_latex_chars(self, text):
|
|
"""Échappe les caractères spéciaux LaTeX"""
|
|
# Ordre important : & avant les autres car on utilise \&
|
|
replacements = [
|
|
('\\', '\\textbackslash{}'), # \ doit être en premier
|
|
('&', '\\&'),
|
|
('%', '\\%'),
|
|
('$', '\\$'),
|
|
('#', '\\#'),
|
|
('^', '\\textasciicircum{}'),
|
|
('_', '\\_'),
|
|
('{', '\\{'),
|
|
('}', '\\}'),
|
|
('~', '\\textasciitilde{}'),
|
|
]
|
|
|
|
for char, replacement in replacements:
|
|
text = text.replace(char, replacement)
|
|
|
|
return text
|
|
|
|
def clean_html_tags(self, text):
|
|
"""Supprime les balises HTML et décode les entités"""
|
|
# Supprimer les balises HTML
|
|
text = re.sub(r'<[^>]+>', '', text)
|
|
# Décoder les entités HTML
|
|
text = html.unescape(text)
|
|
# Nettoyer les espaces multiples et les sauts de ligne
|
|
text = re.sub(r'\s+', ' ', text).strip()
|
|
# Échapper les caractères spéciaux LaTeX
|
|
text = self.escape_latex_chars(text)
|
|
return text
|
|
|
|
def format_code_with_line_breaks(self, code, max_line_length=60):
|
|
"""Formate le code en ajoutant des sauts de ligne si les lignes sont trop longues"""
|
|
lines = code.split('\n')
|
|
formatted_lines = []
|
|
|
|
for line in lines:
|
|
# Si la ligne est plus courte que la limite, la garder telle quelle
|
|
if len(line) <= max_line_length:
|
|
formatted_lines.append(line)
|
|
else:
|
|
# Essayer de couper intelligemment
|
|
current_line = line
|
|
while len(current_line) > max_line_length:
|
|
# Points de coupure préférés en Python
|
|
break_points = [', ', ' = ', ' + ', ' - ', ' * ', ' / ', ' and ', ' or ', ' if ', ' for ', ' in ']
|
|
|
|
best_break = -1
|
|
for bp in break_points:
|
|
# Chercher le dernier point de coupure avant la limite
|
|
pos = current_line[:max_line_length].rfind(bp)
|
|
if pos > best_break and pos > max_line_length // 2: # Au moins à mi-chemin
|
|
best_break = pos + len(bp)
|
|
|
|
if best_break > 0:
|
|
# Couper au meilleur point trouvé
|
|
formatted_lines.append(current_line[:best_break].rstrip())
|
|
current_line = ' ' + current_line[best_break:].lstrip() # Indenter la suite
|
|
else:
|
|
# Pas de bon point de coupure, couper brutalement
|
|
formatted_lines.append(current_line[:max_line_length])
|
|
current_line = ' ' + current_line[max_line_length:] # Indenter la suite
|
|
|
|
# Ajouter le reste de la ligne
|
|
if current_line.strip():
|
|
formatted_lines.append(current_line)
|
|
|
|
return '\n'.join(formatted_lines)
|
|
|
|
def extract_code_from_pre(self, pre_element):
|
|
"""Extrait le code Python d'un élément <pre><code>"""
|
|
code_element = pre_element.find('code')
|
|
if not code_element:
|
|
return ""
|
|
|
|
# Extraire le texte en préservant la structure
|
|
code_lines = []
|
|
for line in code_element.get_text().split('\n'):
|
|
# Supprimer les espaces en début de ligne de façon cohérente
|
|
line = line.rstrip()
|
|
code_lines.append(line)
|
|
|
|
# Supprimer les lignes vides au début et à la fin
|
|
while code_lines and not code_lines[0].strip():
|
|
code_lines.pop(0)
|
|
while code_lines and not code_lines[-1].strip():
|
|
code_lines.pop()
|
|
|
|
raw_code = '\n'.join(code_lines)
|
|
|
|
# Formater le code avec des sauts de ligne
|
|
formatted_code = self.format_code_with_line_breaks(raw_code)
|
|
|
|
return formatted_code
|
|
|
|
def save_code_to_file(self, code, filename):
|
|
"""Sauvegarde le code Python dans un fichier"""
|
|
filepath = self.output_dir / filename
|
|
with open(filepath, 'w', encoding='utf-8') as f:
|
|
f.write(code)
|
|
return filepath
|
|
|
|
def process_code_blocks(self, element):
|
|
"""Traite les blocs de code et retourne le texte avec les références minted"""
|
|
result_parts = []
|
|
|
|
for child in element.children:
|
|
if child.name == 'pre':
|
|
# C'est un bloc de code
|
|
code = self.extract_code_from_pre(child)
|
|
if code.strip():
|
|
self.code_counter += 1
|
|
filename = f"code_{self.code_counter:03d}.py"
|
|
self.save_code_to_file(code, filename)
|
|
result_parts.append(f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}")
|
|
elif child.name:
|
|
# Autre balise HTML
|
|
result_parts.append(self.clean_html_tags(str(child)))
|
|
else:
|
|
# Texte simple
|
|
result_parts.append(str(child).strip())
|
|
|
|
return '\n\n'.join(part for part in result_parts if part.strip())
|
|
|
|
def extract_inline_code(self, text):
|
|
"""Extrait et formate le code Python inline"""
|
|
# D'abord échapper les caractères LaTeX normaux
|
|
text = self.escape_latex_chars(text)
|
|
|
|
# Ensuite traiter le code inline (après échappement pour éviter les conflits)
|
|
# Recherche de code Python simple (variables, méthodes, etc.)
|
|
patterns = [
|
|
# Méthodes avec parenthèses
|
|
(r'([a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*\([^)]*\))', r'\\texttt{\1}'),
|
|
# Indexation avec crochets
|
|
(r'([a-zA-Z_][a-zA-Z0-9_]*\[[^\]]+\])', r'\\texttt{\1}'),
|
|
# Listes littérales
|
|
(r'(\[[^\]]*\])', r'\\texttt{\1}'),
|
|
]
|
|
|
|
for pattern, replacement in patterns:
|
|
text = re.sub(pattern, replacement, text)
|
|
|
|
return text
|
|
|
|
def parse_question(self, question_html):
|
|
"""Parse une question HTML et retourne les données structurées"""
|
|
soup = BeautifulSoup(question_html, 'html.parser')
|
|
|
|
# Extraire le numéro de question
|
|
question_match = re.search(r'Q(\d+)', question_html)
|
|
if not question_match:
|
|
return None
|
|
|
|
question_num = question_match.group(1)
|
|
|
|
# Vérifier s'il y a plusieurs questions dans ce HTML
|
|
all_question_matches = list(re.finditer(r'Q(\d+)\s*-', question_html))
|
|
if len(all_question_matches) > 1:
|
|
# Il y a plusieurs questions, extraire seulement la première
|
|
first_q_start = all_question_matches[0].start()
|
|
second_q_start = all_question_matches[1].start()
|
|
|
|
# Chercher le début du tag <p> qui contient la première question
|
|
p_start = question_html.rfind('<p>', 0, first_q_start + 50)
|
|
if p_start != -1:
|
|
first_q_start = p_start
|
|
|
|
question_html = question_html[first_q_start:second_q_start]
|
|
soup = BeautifulSoup(question_html, 'html.parser')
|
|
|
|
if self.debug:
|
|
print(f"DEBUG Q{question_num} - Question tronquée (multiple détectée) de {first_q_start} à {second_q_start}")
|
|
print(f"DEBUG Q{question_num} - HTML tronqué: {question_html[:150]}...")
|
|
|
|
# Séparer le HTML en section question et section réponses
|
|
responses_section_start = question_html.find("Réponses :")
|
|
if responses_section_start != -1:
|
|
question_html_part = question_html[:responses_section_start]
|
|
responses_html_part = question_html[responses_section_start:]
|
|
else:
|
|
question_html_part = question_html
|
|
responses_html_part = ""
|
|
|
|
# Parser la section question de manière séquentielle
|
|
question_soup = BeautifulSoup(question_html_part, 'html.parser')
|
|
question_parts = []
|
|
|
|
# Si le HTML est mal formé (pas de <p> d'ouverture), le réparer
|
|
if not question_html_part.strip().startswith('<'):
|
|
question_html_part = '<p>' + question_html_part
|
|
question_soup = BeautifulSoup(question_html_part, 'html.parser')
|
|
|
|
# Traiter séquentiellement tous les éléments de la question
|
|
for element in question_soup.find_all(['p', 'pre']):
|
|
if element.name == 'p':
|
|
text = element.get_text().strip()
|
|
if f'Q{question_num}' in text:
|
|
# Texte principal de la question (sans le numéro)
|
|
# Trouver la position de Q{num} et prendre le texte après
|
|
q_pos = text.find(f'Q{question_num}')
|
|
if q_pos != -1:
|
|
clean_text = text[q_pos + len(f'Q{question_num}'):].strip(' -').strip()
|
|
clean_text = self.clean_html_tags(clean_text)
|
|
if clean_text:
|
|
question_parts.append(clean_text)
|
|
elif text and text != "Réponses :" and not re.match(r'^Q\d+\s*-', text):
|
|
# Texte additionnel de la question (éviter les questions parasites)
|
|
clean_text = self.clean_html_tags(text)
|
|
if clean_text:
|
|
question_parts.append(clean_text)
|
|
elif element.name == 'pre':
|
|
# Bloc de code dans la question
|
|
code = self.extract_code_from_pre(element)
|
|
if code.strip():
|
|
self.code_counter += 1
|
|
filename = f"q{question_num}_{self.code_counter}.py"
|
|
self.save_code_to_file(code, filename)
|
|
question_parts.append(f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}")
|
|
|
|
# Parser les réponses de manière plus intelligente
|
|
responses = []
|
|
if responses_html_part:
|
|
responses_soup = BeautifulSoup(responses_html_part, 'html.parser')
|
|
|
|
# Analyser séquentiellement tous les éléments après "Réponses :"
|
|
response_elements = responses_soup.find_all(['p', 'pre'])
|
|
current_response = None
|
|
|
|
for element in response_elements:
|
|
if element.name == 'p':
|
|
text = element.get_text().strip()
|
|
|
|
# Vérifier si c'est le début d'une nouvelle question (Q[0-9]+)
|
|
if re.match(r'^Q\d+\s*-', text):
|
|
if self.debug:
|
|
print(f"DEBUG Q{question_num} - Nouvelle question détectée dans les réponses: {text[:50]}")
|
|
break # Arrêter le parsing des réponses
|
|
|
|
if self.debug:
|
|
print(f"DEBUG Q{question_num} - Element P: {repr(text[:100])}")
|
|
|
|
# Vérifier si c'est une nouvelle réponse (A-, B-, C-, D-)
|
|
response_match = re.match(r'^(>|>)?([A-D])-\s*(.*)', text)
|
|
if response_match:
|
|
# Sauvegarder la réponse précédente si elle existe
|
|
if current_response is not None:
|
|
responses.append(current_response)
|
|
|
|
# Commencer une nouvelle réponse
|
|
prefix = response_match.group(1) or ""
|
|
letter = response_match.group(2)
|
|
response_text = response_match.group(3).strip()
|
|
|
|
# Nettoyer le texte
|
|
if response_text:
|
|
response_text = self.clean_html_tags(response_text)
|
|
|
|
# Vérifier si c'est la bonne réponse
|
|
is_correct = element.find('span', class_='ok') is not None
|
|
|
|
current_response = {
|
|
'letter': letter,
|
|
'text': response_text,
|
|
'correct': is_correct
|
|
}
|
|
|
|
if self.debug:
|
|
print(f"DEBUG Q{question_num} - Nouvelle réponse {letter}: '{response_text}' (correct: {is_correct})")
|
|
|
|
elif text == "Réponses :" or not text.strip():
|
|
# Ignorer les titres et paragraphes vides
|
|
continue
|
|
else:
|
|
# Texte additionnel pour la réponse actuelle
|
|
if current_response is not None and text:
|
|
additional_text = self.clean_html_tags(text)
|
|
if current_response['text']:
|
|
current_response['text'] += " " + additional_text
|
|
else:
|
|
current_response['text'] = additional_text
|
|
|
|
elif element.name == 'pre':
|
|
# Bloc de code pour la réponse actuelle
|
|
if current_response is not None:
|
|
code = self.extract_code_from_pre(element)
|
|
if code.strip():
|
|
self.code_counter += 1
|
|
filename = f"r{question_num}_{current_response['letter'].lower()}.py"
|
|
self.save_code_to_file(code, filename)
|
|
code_block = f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}"
|
|
|
|
if current_response['text']:
|
|
current_response['text'] += "\n\n" + code_block
|
|
else:
|
|
current_response['text'] = code_block
|
|
|
|
if self.debug:
|
|
print(f"DEBUG Q{question_num} - Code ajouté à réponse {current_response['letter']}")
|
|
|
|
# Ajouter la dernière réponse
|
|
if current_response is not None:
|
|
responses.append(current_response)
|
|
|
|
# Vérification de sécurité : chercher des bonnes réponses manquées
|
|
if not any(resp['correct'] for resp in responses):
|
|
# Chercher dans le HTML brut des patterns de bonne réponse
|
|
if '<span class="ok">' in responses_html_part:
|
|
# Chercher quel élément contient le span.ok
|
|
ok_elements = responses_soup.find_all('span', class_='ok')
|
|
for ok_element in ok_elements:
|
|
parent_text = ok_element.parent.get_text() if ok_element.parent else ""
|
|
|
|
# Déterminer quelle lettre correspond
|
|
for letter in ['A', 'B', 'C', 'D']:
|
|
if f'{letter}-' in parent_text or f'>{letter}-' in parent_text or f'>{letter}-' in parent_text:
|
|
# Marquer cette réponse comme correcte
|
|
for resp in responses:
|
|
if resp['letter'] == letter:
|
|
resp['correct'] = True
|
|
if self.debug:
|
|
print(f"DEBUG Q{question_num} - Correction: Réponse {letter} marquée comme correcte")
|
|
break
|
|
break
|
|
|
|
# Convertir en format final (sans la lettre)
|
|
final_responses = []
|
|
for resp in responses:
|
|
final_responses.append({
|
|
'text': resp['text'],
|
|
'correct': resp['correct']
|
|
})
|
|
responses = final_responses
|
|
|
|
# S'assurer qu'on a exactement 4 réponses
|
|
while len(responses) < 4:
|
|
responses.append({
|
|
'text': '',
|
|
'correct': False
|
|
})
|
|
|
|
# Limiter à 4 réponses maximum
|
|
responses = responses[:4]
|
|
|
|
if self.debug:
|
|
print(f"DEBUG Q{question_num} - HTML partie question: {question_html_part[:200]}...")
|
|
print(f"DEBUG Q{question_num} - HTML partie réponses: {responses_html_part[:200]}...")
|
|
print(f"DEBUG Q{question_num} - Question finale: {len(question_parts)} parties")
|
|
print(f"DEBUG Q{question_num} - Parties de question: {[part[:50] + '...' if len(part) > 50 else part for part in question_parts]}")
|
|
print(f"DEBUG Q{question_num} - Réponses finales: {len(responses)} réponses")
|
|
for i, resp in enumerate(responses):
|
|
correct_marker = "✓" if resp['correct'] else "✗"
|
|
text_preview = resp['text'][:80] + ('...' if len(resp['text']) > 80 else '')
|
|
print(f"DEBUG Q{question_num} - Réponse {['A','B','C','D'][i]}: {correct_marker} {text_preview}")
|
|
|
|
return {
|
|
'number': question_num,
|
|
'text': '\n\n'.join(question_parts),
|
|
'responses': responses
|
|
}
|
|
|
|
def get_latex_header(self):
|
|
"""Retourne l'en-tête LaTeX complet pour AMC"""
|
|
return """\\documentclass[12pt,a4paper]{article}
|
|
|
|
\\usepackage{csvsimple}%
|
|
|
|
\\usepackage[francais,bloc]{automultiplechoice}
|
|
|
|
\\usepackage{minted}
|
|
%\\usepackage{ulem}
|
|
\\usepackage{multicol}
|
|
\\let\\multicolmulticols\\multicols
|
|
\\let\\endmulticolmulticols\\endmulticols
|
|
|
|
\\RenewDocumentEnvironment{multicols}{mO{}}
|
|
{%
|
|
\\ifnum#1=1
|
|
#2%
|
|
\\else % More than 1 column
|
|
\\multicolmulticols{#1}[#2]
|
|
\\fi
|
|
}
|
|
{%
|
|
\\ifnum#1=1
|
|
\\else % More than 1 column
|
|
\\endmulticolmulticols
|
|
\\fi
|
|
}
|
|
|
|
|
|
\\usepackage{xcolor}
|
|
\\definecolor{LightGray}{gray}{0.9}
|
|
|
|
% Définition des commandes AMC pour éviter les erreurs
|
|
\\def\\nom{NOM}
|
|
\\def\\prenom{PRENOM}
|
|
\\def\\id{NUMERO}
|
|
|
|
\\newcommand{\\sujet}{
|
|
\\exemplaire{1}{%
|
|
|
|
%%% debut de l'en-tête des copies :
|
|
\\begin{center}
|
|
\\noindent{}\\fbox{\\vspace*{3mm}
|
|
\\Large\\bf\\nom{}~\\prenom{}\\normalsize{}%
|
|
\\vspace*{3mm}
|
|
}
|
|
\\end{center}
|
|
|
|
\\noindent{\\bf QCM \\hfill TEST}
|
|
|
|
\\vspace*{.5cm}
|
|
\\begin{minipage}{.4\\linewidth}
|
|
\\centering\\large\\bf Test\\\\ Examen du 01/01/2008
|
|
\\end{minipage}
|
|
|
|
\\begin{center}\\em
|
|
Durée : 10 minutes.
|
|
|
|
Aucun document n'est autorisé.
|
|
L'usage de la calculatrice est interdit.
|
|
|
|
Les questions faisant apparaître le symbole \\multiSymbole{} peuvent
|
|
présenter zéro, une ou plusieurs bonnes réponses. Les autres ont
|
|
une unique bonne réponse.
|
|
|
|
Des points négatifs pourront être affectés à de \\emph{très
|
|
mauvaises} réponses.
|
|
\\end{center}
|
|
\\vspace{1ex}
|
|
%%% fin de l'en-tête
|
|
|
|
\\restituegroupe{""" + self.element_group + """}
|
|
|
|
|
|
\\AMCassociation{\\id}
|
|
|
|
%\\AMCaddpagesto{3}
|
|
}
|
|
}
|
|
|
|
%%%%§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§
|
|
|
|
\\begin{document}
|
|
%%%Options
|
|
\\AMCrandomseed{1237893}
|
|
|
|
\\def\\AMCformQuestion#1{{\\sc Question #1 :}}
|
|
|
|
\\setdefaultgroupmode{withoutreplacement}
|
|
%%% Fin Options
|
|
|
|
%%% groupes
|
|
|
|
% Questions générées automatiquement"""
|
|
|
|
def get_latex_footer(self):
|
|
"""Retourne le pied de page LaTeX pour AMC"""
|
|
if self.use_csv:
|
|
return f"""
|
|
|
|
\\csvreader[head to column names]{{liste.csv}}{{}}{{\\sujet}}
|
|
|
|
\\end{{document}}"""
|
|
else:
|
|
return f"""
|
|
|
|
\\sujet
|
|
|
|
\\end{{document}}"""
|
|
|
|
def analyze_code_complexity(self, question_text, responses):
|
|
"""Analyse la complexité du code dans la question et les réponses"""
|
|
# Compter les blocs de code dans la question
|
|
question_code_blocks = question_text.count('\\inputminted')
|
|
|
|
# Détecter les codes longs dans la question (plus de 2 blocs = complexe)
|
|
has_long_question_code = question_code_blocks > 2
|
|
|
|
# Analyser les réponses
|
|
response_code_blocks = sum(1 for resp in responses if '\\inputminted' in resp['text'])
|
|
has_code_in_responses = response_code_blocks > 0
|
|
|
|
return {
|
|
'question_code_blocks': question_code_blocks,
|
|
'response_code_blocks': response_code_blocks,
|
|
'has_long_question_code': has_long_question_code,
|
|
'has_code_in_responses': has_code_in_responses
|
|
}
|
|
|
|
def should_use_horizontal_layout(self, question_text, responses):
|
|
"""Détermine si on doit utiliser un layout horizontal ou vertical"""
|
|
# Si un layout est forcé, l'utiliser
|
|
if self.force_layout == 'horizontal':
|
|
return True
|
|
elif self.force_layout == 'vertical':
|
|
return False
|
|
|
|
# Analyser la complexité du code
|
|
code_analysis = self.analyze_code_complexity(question_text, responses)
|
|
|
|
# Si la question a beaucoup de code, forcer vertical pour les réponses
|
|
if code_analysis['has_long_question_code']:
|
|
return False
|
|
|
|
# Si les réponses contiennent du code, forcer vertical
|
|
if code_analysis['has_code_in_responses']:
|
|
return False
|
|
|
|
# Vérifier si toutes les réponses sont courtes
|
|
all_short = all(len(resp['text']) <= self.layout_threshold for resp in responses)
|
|
|
|
# Vérifier si aucune réponse ne contient de code multilignes
|
|
no_multiline_code = all('\\inputminted' not in resp['text'] and '\n' not in resp['text']
|
|
for resp in responses)
|
|
|
|
return all_short and no_multiline_code
|
|
|
|
def should_use_multicol(self, question_text, responses):
|
|
"""Détermine si on doit utiliser multicol pour cette question"""
|
|
if not self.use_multicol:
|
|
return False
|
|
|
|
# Analyser la complexité du code
|
|
code_analysis = self.analyze_code_complexity(question_text, responses)
|
|
|
|
# Ne pas utiliser multicol si la question a beaucoup de code
|
|
if code_analysis['has_long_question_code']:
|
|
return False
|
|
|
|
# Ne jamais utiliser multicol s'il y a du code dans les réponses
|
|
if code_analysis['has_code_in_responses']:
|
|
return False
|
|
|
|
# Calculer la longueur moyenne des réponses
|
|
valid_responses = [resp for resp in responses if resp['text'].strip()]
|
|
if not valid_responses:
|
|
return False
|
|
|
|
avg_length = sum(len(resp['text']) for resp in valid_responses) / len(valid_responses)
|
|
max_length = max(len(resp['text']) for resp in valid_responses)
|
|
|
|
# Utiliser multicol seulement pour les réponses très courtes
|
|
return avg_length <= 25 and max_length <= 35
|
|
|
|
def determine_multicol_columns(self, responses):
|
|
"""Détermine le nombre optimal de colonnes pour multicol"""
|
|
valid_responses = [resp for resp in responses if resp['text'].strip()]
|
|
if not valid_responses:
|
|
return 2
|
|
|
|
avg_length = sum(len(resp['text']) for resp in valid_responses) / len(valid_responses)
|
|
max_length = max(len(resp['text']) for resp in valid_responses)
|
|
|
|
# Réponses très courtes -> 4 colonnes
|
|
if avg_length <= 15 and max_length <= 20 and len(valid_responses) == 4:
|
|
return 4
|
|
# Réponses courtes -> 2 colonnes
|
|
elif avg_length <= 25 and max_length <= 35:
|
|
return 2
|
|
else:
|
|
return 2 # fallback
|
|
|
|
def generate_latex(self, question_data):
|
|
"""Génère le code LaTeX pour une question"""
|
|
latex_parts = []
|
|
|
|
# Encapsuler chaque question dans son propre element (même groupe pour toutes)
|
|
latex_parts.append(f"\\element{{{self.element_group}}}{{")
|
|
latex_parts.append(f"\\begin{{question}}{{q{question_data['number']}}}")
|
|
latex_parts.append("")
|
|
latex_parts.append(question_data['text'])
|
|
latex_parts.append("")
|
|
|
|
# Choisir le layout selon la longueur des réponses et la complexité du code
|
|
use_horizontal = self.should_use_horizontal_layout(question_data['text'], question_data['responses'])
|
|
use_multicol = self.should_use_multicol(question_data['text'], question_data['responses'])
|
|
|
|
# Filtrer les réponses vides
|
|
valid_responses = [resp for resp in question_data['responses'] if resp['text'].strip()]
|
|
|
|
if use_multicol and len(valid_responses) > 0:
|
|
# Déterminer le nombre optimal de colonnes
|
|
optimal_columns = self.determine_multicol_columns(valid_responses)
|
|
layout_env = "reponses"
|
|
latex_parts.append(f" \\begin{{multicols}}{{{optimal_columns}}}")
|
|
latex_parts.append(f" \\begin{{{layout_env}}}")
|
|
else:
|
|
layout_env = "reponseshoriz" if use_horizontal else "reponses"
|
|
latex_parts.append(f" \\begin{{{layout_env}}}")
|
|
|
|
if self.debug:
|
|
code_analysis = self.analyze_code_complexity(question_data['text'], question_data['responses'])
|
|
total_length = sum(len(resp['text']) for resp in valid_responses)
|
|
avg_length = total_length / len(valid_responses) if valid_responses else 0
|
|
|
|
layout_info = f"{layout_env}"
|
|
if use_multicol:
|
|
optimal_columns = self.determine_multicol_columns(valid_responses)
|
|
layout_info += f" + multicol({optimal_columns})"
|
|
if self.force_layout:
|
|
layout_info += f" (forcé: {self.force_layout})"
|
|
if code_analysis['has_long_question_code']:
|
|
layout_info += " (vertical: code long dans question)"
|
|
elif code_analysis['has_code_in_responses']:
|
|
layout_info += " (vertical: code dans réponses)"
|
|
|
|
print(f"DEBUG Q{question_data['number']} - Layout: {layout_info}")
|
|
print(f"DEBUG Q{question_data['number']} - Code: {code_analysis['question_code_blocks']} blocs question, {code_analysis['response_code_blocks']} blocs réponses")
|
|
print(f"DEBUG Q{question_data['number']} - Réponses: {len(valid_responses)} valides, longueur moy: {avg_length:.1f}")
|
|
|
|
for resp in valid_responses:
|
|
command = "\\bonne" if resp['correct'] else "\\mauvaise"
|
|
latex_parts.append(f" {command}{{{resp['text']}}}")
|
|
|
|
if use_multicol and len(valid_responses) > 0:
|
|
latex_parts.append(f" \\end{{{layout_env}}}")
|
|
latex_parts.append(f" \\end{{multicols}}")
|
|
else:
|
|
latex_parts.append(f" \\end{{{layout_env}}}")
|
|
|
|
latex_parts.append("\\end{question}")
|
|
latex_parts.append("}") # Fermer l'element
|
|
latex_parts.append("")
|
|
|
|
return '\n'.join(latex_parts)
|
|
|
|
def parse_html_file(self, html_content):
|
|
"""Parse un fichier HTML complet et extrait toutes les questions"""
|
|
# D'abord essayer de séparer par <hr>
|
|
if '<hr>' in html_content:
|
|
questions_html = html_content.split('<hr>')
|
|
else:
|
|
# Pas de <hr>, chercher les questions par pattern Q[0-9]+
|
|
questions_html = []
|
|
|
|
# Chercher tous les patterns Q[numéro] dans le texte
|
|
question_pattern = r'<p>\s*Q(\d+)\s*-'
|
|
matches = list(re.finditer(question_pattern, html_content))
|
|
|
|
if matches:
|
|
for i, match in enumerate(matches):
|
|
start = match.start()
|
|
# Fin = début de la question suivante ou fin du document
|
|
end = matches[i + 1].start() if i + 1 < len(matches) else len(html_content)
|
|
|
|
question_html = html_content[start:end]
|
|
questions_html.append(question_html)
|
|
|
|
if self.debug:
|
|
question_num = match.group(1)
|
|
print(f"DEBUG - Question Q{question_num} trouvée de {start} à {end}")
|
|
else:
|
|
# Fallback : traiter tout comme une seule question
|
|
questions_html = [html_content]
|
|
|
|
questions_data = []
|
|
for q_html in questions_html:
|
|
if 'Q' in q_html and '- ' in q_html: # Vérifier qu'il y a bien une question
|
|
question_data = self.parse_question(q_html)
|
|
if question_data:
|
|
questions_data.append(question_data)
|
|
|
|
return questions_data
|
|
|
|
def convert_file(self, input_file, output_file, full_document=False):
|
|
"""Convertit un fichier HTML en LaTeX"""
|
|
with open(input_file, 'r', encoding='utf-8') as f:
|
|
html_content = f.read()
|
|
|
|
questions = self.parse_html_file(html_content)
|
|
|
|
latex_content = []
|
|
|
|
if full_document:
|
|
latex_content.append(self.get_latex_header())
|
|
else:
|
|
latex_content.append("% Questions générées automatiquement")
|
|
|
|
latex_content.append("")
|
|
|
|
for question in questions:
|
|
latex_content.append(self.generate_latex(question))
|
|
|
|
if full_document:
|
|
latex_content.append(self.get_latex_footer())
|
|
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(latex_content))
|
|
|
|
print(f"Conversion terminée !")
|
|
print(f"- {len(questions)} questions converties")
|
|
print(f"- {self.code_counter} fichiers de code créés dans {self.output_dir}/")
|
|
print(f"- Fichier LaTeX généré : {output_file}")
|
|
if full_document:
|
|
print(f"- Document LaTeX complet généré (prêt à compiler)")
|
|
if self.use_csv:
|
|
print(f"- Mode CSV activé (nécessite liste.csv)")
|
|
else:
|
|
print(f"- Mode simple activé (\\sujet)")
|
|
print(f"- Groupe d'éléments : {self.element_group}")
|
|
if self.force_layout:
|
|
print(f"- Layout forcé : {self.force_layout}")
|
|
if self.use_multicol:
|
|
print(f"- Multicol activé : {self.multicol_columns} colonnes pour réponses courtes")
|
|
if self.debug:
|
|
print(f"- Mode debug était activé")
|
|
|
|
def main():
|
|
"""Fonction principale"""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description='Convertit un QCM HTML vers LaTeX AMC')
|
|
parser.add_argument('input_file', help='Fichier HTML d\'entrée')
|
|
parser.add_argument('-o', '--output', default='qcm.tex', help='Fichier LaTeX de sortie')
|
|
parser.add_argument('-c', '--codes-dir', default='codes', help='Répertoire pour les fichiers de code')
|
|
parser.add_argument('-d', '--debug', action='store_true', help='Active le mode debug avec informations détaillées')
|
|
parser.add_argument('-f', '--full-document', action='store_true', help='Génère un document LaTeX complet (avec documentclass, packages et structure AMC)')
|
|
parser.add_argument('--csv', action='store_true', help='Utilise \\csvreader au lieu de \\sujet (nécessite liste.csv)')
|
|
parser.add_argument('-g', '--element-group', default='general', help='Nom du groupe d\'éléments pour AMC (défaut: general)')
|
|
|
|
# Options de layout
|
|
layout_group = parser.add_argument_group('Options de mise en page')
|
|
layout_group.add_argument('--layout-threshold', type=int, default=50, help='Seuil de longueur pour choisir le layout horizontal (défaut: 50)')
|
|
layout_group.add_argument('--force-horizontal', action='store_true', help='Force le layout horizontal pour toutes les réponses')
|
|
layout_group.add_argument('--force-vertical', action='store_true', help='Force le layout vertical pour toutes les réponses')
|
|
layout_group.add_argument('--multicol', action='store_true', help='Active l\'utilisation de multicol pour les réponses très courtes')
|
|
layout_group.add_argument('--multicol-columns', type=int, default=2, help='Nombre de colonnes pour multicol (défaut: 2)')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Vérifier les conflits d'options
|
|
if args.force_horizontal and args.force_vertical:
|
|
parser.error("--force-horizontal et --force-vertical ne peuvent pas être utilisés ensemble")
|
|
|
|
# Déterminer le layout forcé
|
|
force_layout = None
|
|
if args.force_horizontal:
|
|
force_layout = 'horizontal'
|
|
elif args.force_vertical:
|
|
force_layout = 'vertical'
|
|
|
|
converter = QCMConverter(
|
|
output_dir=args.codes_dir,
|
|
debug=args.debug,
|
|
layout_threshold=args.layout_threshold,
|
|
force_layout=force_layout,
|
|
use_multicol=args.multicol,
|
|
multicol_columns=args.multicol_columns,
|
|
use_csv=args.csv,
|
|
element_group=args.element_group
|
|
)
|
|
converter.convert_file(args.input_file, args.output, full_document=args.full_document)
|
|
|
|
if __name__ == '__main__':
|
|
main() |