Ajouter html2latex.py

première version
This commit is contained in:
lprik 2025-08-20 08:07:38 +00:00
parent 17557d0a0e
commit 930c7c8185

785
html2latex.py Normal file
View file

@ -0,0 +1,785 @@
#!/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'^(>|&gt;)?([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'&gt;{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()