Unifie la correction des exercices en une page unique
La page de correction ne réagissait pas au dépôt d'un PDF : elle encodait le fichier en base64 dans localStorage puis redirigeait vers une page dédiée censée le relire. Ce relais échoue en file://, qui est le mode d'ouverture habituel des pages. corriger-exercice.html devient autonome : sélection de l'exercice, dépôt du PDF et affichage des réponses se font sur place, sans localStorage ni redirection. Les trois parseurs (BioCID, FDS, TP Nuisibles) sont regroupés derrière un format commun avec carrousel exercice par exercice, bandeau participants, correction chiffrée pour la partie FDS et annexe TP3. Ajoute un garde-fou signalant un PDF ne correspondant pas à l'exercice sélectionné, repointe les boutons « Corriger » de Exo_FDS et TP_Nuisibles vers la page unifiée, et supprime les trois pages de correction dédiées devenues sans référence.
This commit is contained in:
@@ -962,7 +962,7 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
|
||||
</table>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-amber" onclick="exporterPDF()">⬇ Télécharger mes réponses (PDF)</button>
|
||||
<a href="correction-fds.html" class="btn btn-outline">✔ Corriger un rendu</a>
|
||||
<a href="corriger-exercice.html?exo=fds" class="btn btn-outline">✔ Corriger un rendu</a>
|
||||
<button class="btn btn-outline" onclick="restartAll()">↺ Recommencer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -707,7 +707,7 @@
|
||||
<div id="idError" style="color:var(--coral);font-size:13px;margin-bottom:0.5rem;display:none;">Merci de renseigner au moins le nom du groupe.</div>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
|
||||
<button class="btn btn-teal" onclick="confirmIdentite()">Continuer →</button>
|
||||
<a href="correction-tp-nuisibles.html" class="btn btn-outline btn-teal">Corriger</a>
|
||||
<a href="corriger-exercice.html?exo=tp" class="btn btn-outline btn-teal">Corriger</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1160,7 +1160,7 @@
|
||||
<div class="submit-zone">
|
||||
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
|
||||
<button class="btn btn-teal" onclick="exporterPDF()">⬇ Télécharger en PDF</button>
|
||||
<a href="correction-tp-nuisibles.html" class="btn btn-outline btn-teal">Corriger</a>
|
||||
<a href="corriger-exercice.html?exo=tp" class="btn btn-outline btn-teal">Corriger</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:0.75rem;">
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction BioCID — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#participantsBanner .pb-meta {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
text-align: right;
|
||||
opacity: 0.92;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
|
||||
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--amber); transition: width 0.4s ease; }
|
||||
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
|
||||
|
||||
/* CARROUSEL DE THEMES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.miss { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
|
||||
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; }
|
||||
.qcard .qanswer.empty { color: var(--coral); font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction d'exercice</div>
|
||||
<h1>Plateforme BioCID — Lecture du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du groupe ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis l'exercice BioCID</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span><strong id="progressCount"></strong> questions renseignées</span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentTheme - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentTheme + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
// Les 4 exercices, avec les intitulés exacts exportés (data-question) comme clés.
|
||||
const THEMES = [
|
||||
{
|
||||
title: "Exercice 1 — Recherche par nom",
|
||||
icon: "🔎", color: "amber",
|
||||
items: [
|
||||
"Ex1 — Produit : type, substance active, fabricant",
|
||||
"Ex1 — Date d'autorisation",
|
||||
"Ex1 — Conditions d'utilisation",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 2 — Recherche par type",
|
||||
icon: "🗂️", color: "blue",
|
||||
items: [
|
||||
"Ex2 — Nombre de produits dans la catégorie",
|
||||
"Ex2 — Comparaison entre catégories",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 3 — Recherche par substance",
|
||||
icon: "🧪", color: "teal",
|
||||
items: [
|
||||
"Ex3 — 3 produits contenant la substance",
|
||||
"Ex3 — Formulations et concentrations comparées",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 4 — Cas de synthèse",
|
||||
icon: "📞", color: "coral",
|
||||
items: [
|
||||
"Ex4 — 3 produits applicables",
|
||||
"Ex4 — Fiche de chaque produit",
|
||||
"Ex4 — Argumentaire (+ bonus FDS)",
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_SECTIONS = THEMES.flatMap(t => t.items);
|
||||
|
||||
const COLOR_MAP = {
|
||||
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
|
||||
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
function isEmpty(v) {
|
||||
if (!v) return true;
|
||||
const t = v.trim();
|
||||
return t === "" || /^\(sans réponse\)/i.test(t);
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Retire les en-têtes/pieds d'impression du navigateur (date, titre, about:blank) sans toucher au corps
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
|
||||
clean = clean.replace(/Exercices BioCID\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ");
|
||||
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank/gi, " ");
|
||||
clean = clean.replace(/\s+\d{1,3}\/\d{1,3}\s*$/, " ");
|
||||
clean = clean.replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Réponses aux exercices"]),
|
||||
};
|
||||
|
||||
// Sépare "Groupe N" du nom des participants
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
const startIdx = clean.search(/Réponses aux exercices/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx) : clean;
|
||||
|
||||
const answers = {};
|
||||
for (let i = 0; i < ALL_SECTIONS.length; i++) {
|
||||
const label = ALL_SECTIONS[i];
|
||||
const nextLabel = ALL_SECTIONS[i + 1];
|
||||
answers[label] = captureBetween(block, label, nextLabel ? [nextLabel] : []);
|
||||
}
|
||||
|
||||
return { meta, answers };
|
||||
}
|
||||
|
||||
let currentThemeData = null;
|
||||
let currentTheme = 0;
|
||||
|
||||
function renderThemePanel() {
|
||||
const theme = THEMES[currentTheme];
|
||||
const data = currentThemeData;
|
||||
const colors = COLOR_MAP[theme.color];
|
||||
const themeAnswered = theme.items.filter(s => !isEmpty(data.answers[s])).length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${theme.icon}</div>
|
||||
<div class="theme-title">${theme.title}</div>
|
||||
<div class="theme-count">${themeAnswered}/${theme.items.length}</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${theme.items.map(label => {
|
||||
const value = data.answers[label] || "";
|
||||
const empty = isEmpty(value);
|
||||
const displayLabel = label.replace(/^Ex\d+\s*[—-]\s*/, "");
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${empty ? 'miss' : 'ok'}">${empty ? '!' : '✓'}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">${escapeHtml(displayLabel)}</div>
|
||||
<div class="qanswer ${empty ? 'empty' : ''}">${empty ? 'Sans réponse' : escapeHtml(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentTheme === 0;
|
||||
document.getElementById("nextBtn").disabled = currentTheme === THEMES.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentTheme));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= THEMES.length) return;
|
||||
currentTheme = idx;
|
||||
renderThemePanel();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentThemeData = data;
|
||||
currentTheme = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
const noms = data.meta.participants || data.meta.nom;
|
||||
if (noms) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(noms)}</div>
|
||||
</div>
|
||||
<div class="pb-meta">
|
||||
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
|
||||
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) : ""}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
const answeredCount = ALL_SECTIONS.filter(s => !isEmpty(data.answers[s])).length;
|
||||
document.getElementById("progressFill").style.width = (answeredCount / ALL_SECTIONS.length * 100) + "%";
|
||||
document.getElementById("progressCount").textContent = `${answeredCount} / ${ALL_SECTIONS.length}`;
|
||||
|
||||
document.getElementById("dots").innerHTML = THEMES.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderThemePanel();
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export de l'exercice BioCID.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
|
||||
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
|
||||
(function autoLoadFromHub() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("auto") !== "1") return;
|
||||
const dataUrl = localStorage.getItem("correctionFile");
|
||||
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
|
||||
localStorage.removeItem("correctionFile");
|
||||
localStorage.removeItem("correctionFileName");
|
||||
localStorage.removeItem("correctionExo");
|
||||
if (!dataUrl) return;
|
||||
try {
|
||||
const bin = atob(dataUrl.split(",").pop());
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
handleFile(new File([bytes], name, { type: "application/pdf" }));
|
||||
} catch (e) { console.error(e); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,622 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction FDS, FT & Étiquettes — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#participantsBanner .pb-meta {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
text-align: right;
|
||||
opacity: 0.92;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* SCORE GLOBAL */
|
||||
.score-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.score-ring {
|
||||
width: 84px; height: 84px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
font-family: 'Syne', sans-serif;
|
||||
}
|
||||
.score-ring .score-pct { font-size: 22px; font-weight: 800; line-height: 1; }
|
||||
.score-ring .score-lbl { font-size: 9px; letter-spacing: 0.06em; opacity: 0.9; }
|
||||
.score-detail .score-main { font-family: 'Syne', sans-serif; font-size: 18px; font-weight: 800; }
|
||||
.score-detail .score-sub { font-size: 13px; color: var(--text-muted); margin-top: 3px; }
|
||||
.score-parts { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.score-pill {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* CARROUSEL DE PARTIES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.ko { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .badge.miss { background: rgba(0,0,0,0.06); color: var(--text-muted); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 5px; }
|
||||
.qcard .qgroup { font-size: 13px; }
|
||||
.qcard .qgroup.ok { color: var(--teal); }
|
||||
.qcard .qgroup.ko { color: var(--coral); }
|
||||
.qcard .qgroup.miss { color: var(--text-muted); font-style: italic; }
|
||||
.qcard .qexpected { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction de TD</div>
|
||||
<h1>FDS, FT & Étiquettes — Correction du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du groupe ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis le TD FDS, FT & Étiquettes</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="score-card" id="scoreCard"></div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentPart - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentPart + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
/* ==== Clé de correction (identique à l'exercice) ==== */
|
||||
const fdsQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "1" },
|
||||
{ id: 2, text: "Quels sont les dangers du produit ?", answer: "2" },
|
||||
{ id: 3, text: "Quelles sont les molécules actives et leurs concentrations ?", answer: "3" },
|
||||
{ id: 4, text: "Quels gestes adopter en premiers secours ?", answer: "4" },
|
||||
{ id: 5, text: "En cas d'incendie, quels sont les moyens d'extinction conseillés ?", answer: "5" },
|
||||
{ id: 6, text: "Que faire en cas de dispersion accidentelle ?", answer: "6" },
|
||||
{ id: 7, text: "Dans quelles conditions stocker mon produit ?", answer: "7" },
|
||||
{ id: 8, text: "Quels équipements de protection individuelle (EPI) dois-je porter ?", answer: "8" },
|
||||
{ id: 9, text: "Quel est le pH de ce produit ?", answer: "9" },
|
||||
{ id: 10, text: "Le produit est-il stable dans des conditions normales ?", answer: "10" },
|
||||
{ id: 11, text: "Quels sont les effets potentiels sur la santé ?", answer: "11" },
|
||||
{ id: 12, text: "Quel est le degré de toxicité pour les poissons ? Potentiel de bioaccumulation ?", answer: "12" },
|
||||
{ id: 13, text: "Comment éliminer le produit ?", answer: "13" },
|
||||
{ id: 14, text: "Est-ce que mon produit est classé dangereux pour le transport ?", answer: "14" },
|
||||
{ id: 15, text: "En cas d'ingestion du produit, que faire ?", answer: "4,11" },
|
||||
{ id: 16, text: "Où trouver le mode d'emploi du produit ?", answer: "absent" }
|
||||
];
|
||||
const ftQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
|
||||
{ id: 2, text: "Sur quelles matières ce produit est-il compatible ?", answer: "Description" },
|
||||
{ id: 3, text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" },
|
||||
{ id: 4, text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
|
||||
{ id: 5, text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" }
|
||||
];
|
||||
const etiquetteQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et le nom de l'entreprise ?", answer: "Identité" },
|
||||
{ id: 2, text: "Quels pictogrammes de danger sont affichés ?", answer: "Pictogrammes" },
|
||||
{ id: 3, text: "Quels sont les mots d'avertissement (« Attention » ou « Danger ») ?", answer: "Mots d'avertissement" },
|
||||
{ id: 4, text: "Quelles sont les mentions de danger (H-statements) identifiées ?", answer: "Mentions de danger" },
|
||||
{ id: 5, text: "Quels équipements de protection individuelle (EPI) sont recommandés ?", answer: "Conseils de prudence" },
|
||||
{ id: 6, text: "Quels premiers secours sont indiqués ?", answer: "Conseils de prudence" },
|
||||
{ id: 7, text: "Comment éliminer l'emballage après utilisation ?", answer: "Conseils de prudence" },
|
||||
{ id: 8, text: "Quel est le contenu net du produit ?", answer: "Contenu net" },
|
||||
{ id: 9, text: "Y a-t-il un numéro d'appel d'urgence mentionné ?", answer: "Numéro d'urgence" },
|
||||
{ id: 10, text: "Quelles consignes de stockage sont affichées ?", answer: "Conseils de prudence" }
|
||||
];
|
||||
|
||||
const PARTS = [
|
||||
{ prefix: "P1", title: "Partie 1 — FDS", icon: "📄", color: "amber", questions: fdsQuestions, kind: "fds" },
|
||||
{ prefix: "P2", title: "Partie 2 — Fiche Technique", icon: "📋", color: "coral", questions: ftQuestions, kind: "text" },
|
||||
{ prefix: "P3", title: "Partie 3 — Étiquette", icon: "🏷️", color: "teal", questions: etiquetteQuestions, kind: "text" },
|
||||
];
|
||||
const COLOR_MAP = {
|
||||
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
// Extrait la réponse du groupe pour un marqueur [Px-Qn]
|
||||
function extractSelected(fullText, marker, nextMarker) {
|
||||
const seg = captureBetween(fullText, marker, nextMarker ? [nextMarker] : []);
|
||||
const m = /Réponse du groupe\s*:?\s*([\s\S]*?)\s*[—–-]*\s*Attendu\s*:/i.exec(seg);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
/* ==== Correction ==== */
|
||||
function normFdsToken(sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return null;
|
||||
if (/^absent$/i.test(sel.trim())) return "absent";
|
||||
const m = /(\d+)/.exec(sel);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
function scoreFds(q, sel) {
|
||||
const tok = normFdsToken(sel);
|
||||
if (tok === null) return { state: "miss" };
|
||||
return { state: q.answer.split(",").includes(tok) ? "ok" : "ko" };
|
||||
}
|
||||
function scoreText(q, sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return { state: "miss" };
|
||||
const norm = /^absent$/i.test(sel.trim()) ? "absent" : sel.trim();
|
||||
return { state: norm.toLowerCase() === q.answer.toLowerCase() ? "ok" : "ko" };
|
||||
}
|
||||
function expectedLabel(part, q) {
|
||||
if (part.kind === "fds") {
|
||||
const parts = q.answer.split(",");
|
||||
if (parts.length > 1) return "Rubrique " + parts.join(" ou ");
|
||||
return parts[0] === "absent" ? "Absent" : "Rubrique " + parts[0];
|
||||
}
|
||||
return q.answer;
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Nettoie les en-têtes/pieds d'impression du navigateur sans toucher au corps du document
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
|
||||
clean = clean.replace(/FDS, FT (&|&) Étiquettes\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ");
|
||||
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank/gi, " ");
|
||||
clean = clean.replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Score global"]),
|
||||
};
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
// Liste ordonnée de tous les marqueurs pour borner chaque extraction
|
||||
const markers = [];
|
||||
PARTS.forEach(p => p.questions.forEach((q, i) => markers.push("[" + p.prefix + "-Q" + (i + 1) + "]")));
|
||||
|
||||
const selected = {};
|
||||
let mIdx = 0;
|
||||
PARTS.forEach(p => {
|
||||
p.questions.forEach((q, i) => {
|
||||
const marker = "[" + p.prefix + "-Q" + (i + 1) + "]";
|
||||
const next = markers[mIdx + 1];
|
||||
selected[marker] = extractSelected(clean, marker, next);
|
||||
mIdx++;
|
||||
});
|
||||
});
|
||||
|
||||
return { meta, selected };
|
||||
}
|
||||
|
||||
let currentData = null;
|
||||
let currentPart = 0;
|
||||
|
||||
function partResults(part) {
|
||||
return part.questions.map((q, i) => {
|
||||
const marker = "[" + part.prefix + "-Q" + (i + 1) + "]";
|
||||
const sel = (currentData.selected[marker] || "").trim();
|
||||
const res = part.kind === "fds" ? scoreFds(q, sel) : scoreText(q, sel);
|
||||
return { q, i, sel, state: res.state };
|
||||
});
|
||||
}
|
||||
|
||||
function renderPart() {
|
||||
const part = PARTS[currentPart];
|
||||
const colors = COLOR_MAP[part.color];
|
||||
const results = partResults(part);
|
||||
const ok = results.filter(r => r.state === "ok").length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${part.icon}</div>
|
||||
<div class="theme-title">${part.title}</div>
|
||||
<div class="theme-count">${ok}/${results.length} corrects</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${results.map(r => {
|
||||
const badge = r.state === "ok" ? "ok" : (r.state === "ko" ? "ko" : "miss");
|
||||
const badgeIcon = r.state === "ok" ? "✓" : (r.state === "ko" ? "✗" : "?");
|
||||
const groupTxt = r.state === "miss" ? "Sans réponse" : escapeHtml(r.sel);
|
||||
const showExpected = r.state !== "ok";
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${badge}">${badgeIcon}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">Q${r.i + 1}. ${escapeHtml(r.q.text)}</div>
|
||||
<div class="qgroup ${badge}">Réponse du groupe : ${groupTxt}</div>
|
||||
${showExpected ? `<div class="qexpected">Attendu : ${escapeHtml(expectedLabel(part, r.q))}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentPart === 0;
|
||||
document.getElementById("nextBtn").disabled = currentPart === PARTS.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentPart));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= PARTS.length) return;
|
||||
currentPart = idx;
|
||||
renderPart();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentData = data;
|
||||
currentPart = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
const noms = data.meta.participants || data.meta.nom;
|
||||
if (noms) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(noms)}</div>
|
||||
</div>
|
||||
<div class="pb-meta">
|
||||
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
|
||||
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) : ""}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
// Score global + par partie
|
||||
let totalOk = 0, totalNb = 0;
|
||||
const pillHtml = PARTS.map(p => {
|
||||
const results = partResults(p);
|
||||
const ok = results.filter(r => r.state === "ok").length;
|
||||
totalOk += ok; totalNb += results.length;
|
||||
return `<div class="score-pill">${p.icon} ${ok}/${results.length}</div>`;
|
||||
}).join("");
|
||||
|
||||
const pct = totalNb ? Math.round((totalOk / totalNb) * 100) : 0;
|
||||
const ringColor = pct >= 80 ? "var(--teal-mid)" : (pct >= 60 ? "var(--amber)" : "var(--coral-mid)");
|
||||
const verdict = pct >= 80 ? "Très bon rendu" : (pct >= 60 ? "Rendu correct" : "À revoir en priorité");
|
||||
|
||||
document.getElementById("scoreCard").innerHTML = `
|
||||
<div class="score-ring" style="background:${ringColor};">
|
||||
<div class="score-pct">${pct}%</div>
|
||||
<div class="score-lbl">RÉUSSITE</div>
|
||||
</div>
|
||||
<div class="score-detail">
|
||||
<div class="score-main">${totalOk} / ${totalNb} bonnes réponses</div>
|
||||
<div class="score-sub">${verdict}</div>
|
||||
</div>
|
||||
<div class="score-parts">${pillHtml}</div>
|
||||
`;
|
||||
|
||||
document.getElementById("dots").innerHTML = PARTS.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderPart();
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export du TD FDS, FT & Étiquettes.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
|
||||
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
|
||||
(function autoLoadFromHub() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("auto") !== "1") return;
|
||||
const dataUrl = localStorage.getItem("correctionFile");
|
||||
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
|
||||
localStorage.removeItem("correctionFile");
|
||||
localStorage.removeItem("correctionFileName");
|
||||
localStorage.removeItem("correctionExo");
|
||||
if (!dataUrl) return;
|
||||
try {
|
||||
const bin = atob(dataUrl.split(",").pop());
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
handleFile(new File([bytes], name, { type: "application/pdf" }));
|
||||
} catch (e) { console.error(e); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,668 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction TP Nuisibles — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
.id-card { display: none; }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.id-card .label {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--amber-dark);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.id-card .value { font-size: 14.5px; margin-top: 2px; }
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
|
||||
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--teal); transition: width 0.4s ease; }
|
||||
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
|
||||
|
||||
/* CARROUSEL DE THEMES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 420px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.miss { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
|
||||
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; }
|
||||
.qcard .qanswer.empty { color: var(--coral); font-style: italic; }
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* TABLEAU ANALYSE DE RISQUES */
|
||||
.risk-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.risk-table th {
|
||||
font-family: 'Syne', sans-serif;
|
||||
text-align: left;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--amber-dark);
|
||||
background: var(--amber-light);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.risk-table th:first-child { border-radius: 8px 0 0 8px; }
|
||||
.risk-table th:last-child { border-radius: 0 8px 8px 0; }
|
||||
.risk-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.risk-table tr:last-child td { border-bottom: none; }
|
||||
.meta-line { font-size: 12.5px; color: var(--text-muted); margin-top: 20px; }
|
||||
.meta-line strong { color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction de TP</div>
|
||||
<h1>TP Nuisibles — Lecture du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du stagiaire ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis le TP</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="id-card" id="idCard"></div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span><strong id="progressCount"></strong> questions répondues</span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentTheme - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentTheme + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
|
||||
<h2 class="syne" style="font-size:16px;font-weight:800;margin-bottom:12px;color:var(--amber-dark);">Analyse de risques</h2>
|
||||
<div class="table-wrap" id="tp3Meta"></div>
|
||||
<div class="table-wrap" id="tp3Table" style="display:none;"></div>
|
||||
<p class="meta-line" id="suiviLine"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
// Thèmes regroupant les sections du TP, avec icône et couleur
|
||||
const THEMES = [
|
||||
{
|
||||
title: "Premier contact & diagnostic",
|
||||
icon: "🗣️", color: "purple",
|
||||
items: [
|
||||
"Questions posées au client lors du premier contact",
|
||||
"Conseils donnés en premier lieu",
|
||||
"Actions de prévention menées sur le site",
|
||||
"Éléments observés / preuve de présence du nuisible",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Solutions envisagées",
|
||||
icon: "🧰", color: "blue",
|
||||
items: [
|
||||
"Solutions alternatives non-biocides",
|
||||
"Solutions biocides mises en place",
|
||||
"Type de produit (TP) et mode de traitement",
|
||||
"Suivi mis en place et durée",
|
||||
"Gestion des déchets",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Produit & traçabilité",
|
||||
icon: "🧪", color: "teal",
|
||||
items: [
|
||||
"FDS trouvée (éléments principaux)",
|
||||
"Nom du produit et fabricant/fournisseur",
|
||||
"Numéro AMM",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Sécurité & santé",
|
||||
icon: "🛡️", color: "coral",
|
||||
items: [
|
||||
"Risques pour la santé",
|
||||
"EPI à porter",
|
||||
"Risques environnement / dispersion accidentelle",
|
||||
"DL50 substance(s) active(s)",
|
||||
"Gestes de premiers secours",
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_SECTIONS = THEMES.flatMap(t => t.items);
|
||||
|
||||
const COLOR_MAP = {
|
||||
purple: { bg: "var(--purple-light)", fg: "var(--purple)" },
|
||||
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
function isEmpty(v) {
|
||||
if (!v) return true;
|
||||
const t = v.trim();
|
||||
return t === "" || /^\(sans réponse\)$/i.test(t);
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Retire les répétitions d'en-tête de page (ex: "17/06/2026 15:42 Résultats TP — ... about:blank 1/2")
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4}\s+\d{1,2}:\d{2}\s+Résultats TP[^]*?about:blank\s*\d\/\d/gi, " ");
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Cas pratique"]),
|
||||
cas: captureBetween(clean, "Cas pratique", ["Réponses TP1"]),
|
||||
};
|
||||
|
||||
// Sépare "Groupe N" du nom des participants (ex: "Groupe 1 Romain Kevin Valere Greg P")
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
const startIdx = clean.search(/Réponses TP1\s*&?\s*TP2/i);
|
||||
const endIdx = clean.search(/TP3\s*[—-]\s*Analyse de risques/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx, endIdx >= 0 ? endIdx : clean.length) : clean;
|
||||
|
||||
const answers = {};
|
||||
for (let i = 0; i < ALL_SECTIONS.length; i++) {
|
||||
const label = ALL_SECTIONS[i];
|
||||
const nextLabel = ALL_SECTIONS[i + 1];
|
||||
answers[label] = captureBetween(block, label, nextLabel ? [nextLabel] : []);
|
||||
}
|
||||
|
||||
const tp3Zone = endIdx >= 0 ? clean.slice(endIdx) : "";
|
||||
const tp3 = {
|
||||
intitule: captureBetween(tp3Zone, "Intitulé", ["Description"]),
|
||||
description: captureBetween(tp3Zone, "Description", ["Lieu"]),
|
||||
lieu: captureBetween(tp3Zone, "Lieu", ["Début prévu le"]),
|
||||
debut: captureBetween(tp3Zone, "Début prévu le", ["Étapes"]),
|
||||
};
|
||||
|
||||
// Tableau de risques : on découpe en s'appuyant sur les intitulés d'étapes connus du TP
|
||||
const KNOWN_STEPS = [
|
||||
"remplir la poudreuse",
|
||||
"sécurisation du site",
|
||||
"application traitements",
|
||||
"rangement du matériel. purger la perche",
|
||||
"retrait du nid",
|
||||
"gestion des déchets. élimination par des filiaires spé.",
|
||||
];
|
||||
let riskRows = [];
|
||||
const tableMatch = /Étapes\s*Risques\s*Mesures de prévention([\s\S]*?)(Réalisée par|$)/i.exec(tp3Zone);
|
||||
if (tableMatch) {
|
||||
let raw = tableMatch[1].trim();
|
||||
if (raw) {
|
||||
// Repère les positions de chaque étape connue dans le texte aplati
|
||||
const positions = [];
|
||||
KNOWN_STEPS.forEach(step => {
|
||||
const idx = raw.toLowerCase().indexOf(step.toLowerCase());
|
||||
if (idx >= 0) positions.push({ step, idx });
|
||||
});
|
||||
positions.sort((a, b) => a.idx - b.idx);
|
||||
|
||||
if (positions.length > 0) {
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const from = positions[i].idx + positions[i].step.length;
|
||||
const to = i + 1 < positions.length ? positions[i + 1].idx : raw.length;
|
||||
let rest = raw.slice(from, to).trim();
|
||||
// rest contient "risque... mesure..." -> on coupe sur les mots clés de mesures (gants/masques, barrieres, etc.)
|
||||
const measureRx = /(gants\s*\/\s*masques\s*\/\s*lunettes[^.]*|barrieres\.?\s*epi)/i;
|
||||
const mm = measureRx.exec(rest);
|
||||
let risque = rest, mesure = "";
|
||||
if (mm) {
|
||||
risque = rest.slice(0, mm.index).trim();
|
||||
mesure = rest.slice(mm.index).trim();
|
||||
}
|
||||
risque = risque.replace(/^[\s\-–.]+/, "").trim();
|
||||
riskRows.push({ etape: positions[i].step, risque: risque || "—", mesure: mesure || "—" });
|
||||
}
|
||||
} else {
|
||||
riskRows.push({ etape: raw, risque: "", mesure: "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mReal = clean.match(/Réalisée par\s*:\s*([^—\n]*)/i);
|
||||
const mVerif = clean.match(/Vérifiée par\s*:\s*([^—\n]*)/i);
|
||||
const mMaj = clean.match(/Mise à jour par\s*:\s*([^—\n]*)/i);
|
||||
|
||||
return {
|
||||
meta, answers, tp3, riskRows,
|
||||
suivi: {
|
||||
realise: mReal ? mReal[1].trim() : "",
|
||||
verifie: mVerif ? mVerif[1].trim() : "",
|
||||
maj: mMaj ? mMaj[1].trim() : "",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let currentThemeData = null;
|
||||
let currentTheme = 0;
|
||||
|
||||
function renderThemePanel() {
|
||||
const theme = THEMES[currentTheme];
|
||||
const data = currentThemeData;
|
||||
const colors = COLOR_MAP[theme.color];
|
||||
const themeAnswered = theme.items.filter(s => !isEmpty(data.answers[s])).length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${theme.icon}</div>
|
||||
<div class="theme-title">${theme.title}</div>
|
||||
<div class="theme-count">${themeAnswered}/${theme.items.length}</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${theme.items.map(label => {
|
||||
const value = data.answers[label] || "";
|
||||
const empty = isEmpty(value);
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${empty ? 'miss' : 'ok'}">${empty ? '!' : '✓'}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">${escapeHtml(label)}</div>
|
||||
<div class="qanswer ${empty ? 'empty' : ''}">${empty ? 'Sans réponse' : escapeHtml(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentTheme === 0;
|
||||
document.getElementById("nextBtn").disabled = currentTheme === THEMES.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentTheme));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= THEMES.length) return;
|
||||
currentTheme = idx;
|
||||
renderThemePanel();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentThemeData = data;
|
||||
currentTheme = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
if (data.meta.participants) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(data.meta.participants)}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
document.getElementById("idCard").innerHTML = `
|
||||
<div><div class="label">Groupe / Stagiaire</div><div class="value">${escapeHtml(data.meta.nom) || "—"}</div></div>
|
||||
<div><div class="label">Date de session</div><div class="value">${escapeHtml(data.meta.date_session) || "—"}</div></div>
|
||||
<div><div class="label">Formateur</div><div class="value">${escapeHtml(data.meta.formateur) || "—"}</div></div>
|
||||
<div><div class="label">Cas pratique</div><div class="value">${escapeHtml(data.meta.cas) || "—"}</div></div>
|
||||
`;
|
||||
|
||||
const answeredCount = ALL_SECTIONS.filter(s => !isEmpty(data.answers[s])).length;
|
||||
document.getElementById("progressFill").style.width = (answeredCount / ALL_SECTIONS.length * 100) + "%";
|
||||
document.getElementById("progressCount").textContent = `${answeredCount} / ${ALL_SECTIONS.length}`;
|
||||
|
||||
document.getElementById("dots").innerHTML = THEMES.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderThemePanel();
|
||||
|
||||
document.getElementById("tp3Meta").innerHTML = `
|
||||
<p style="margin-bottom:6px;"><strong>Intitulé :</strong> ${escapeHtml(data.tp3.intitule) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p style="margin-bottom:6px;"><strong>Description :</strong> ${escapeHtml(data.tp3.description) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p style="margin-bottom:6px;"><strong>Lieu :</strong> ${escapeHtml(data.tp3.lieu) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p><strong>Début prévu le :</strong> ${escapeHtml(data.tp3.debut) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
`;
|
||||
|
||||
const tp3TableEl = document.getElementById("tp3Table");
|
||||
if (data.riskRows && data.riskRows.length > 0) {
|
||||
tp3TableEl.style.display = "block";
|
||||
tp3TableEl.innerHTML = `
|
||||
<table class="risk-table">
|
||||
<thead><tr><th>Étapes</th><th>Risques</th><th>Mesures de prévention</th></tr></thead>
|
||||
<tbody>
|
||||
${data.riskRows.map(r => `
|
||||
<tr>
|
||||
<td>${escapeHtml(r.etape) || "—"}</td>
|
||||
<td>${escapeHtml(r.risque) || "—"}</td>
|
||||
<td>${escapeHtml(r.mesure) || "—"}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
tp3TableEl.style.display = "none";
|
||||
}
|
||||
|
||||
document.getElementById("suiviLine").innerHTML =
|
||||
`Réalisée par : <strong>${escapeHtml(data.suivi.realise) || "—"}</strong> — Vérifiée par : <strong>${escapeHtml(data.suivi.verifie) || "—"}</strong> — Mise à jour par : <strong>${escapeHtml(data.suivi.maj) || "—"}</strong>`;
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export du TP.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
document.getElementById("tp3Table").style.display = "none";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
|
||||
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
|
||||
(function autoLoadFromHub() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("auto") !== "1") return;
|
||||
const dataUrl = localStorage.getItem("correctionFile");
|
||||
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
|
||||
localStorage.removeItem("correctionFile");
|
||||
localStorage.removeItem("correctionFileName");
|
||||
localStorage.removeItem("correctionExo");
|
||||
if (!dataUrl) return;
|
||||
try {
|
||||
const bin = atob(dataUrl.split(",").pop());
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
handleFile(new File([bytes], name, { type: "application/pdf" }));
|
||||
} catch (e) { console.error(e); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,7 +10,7 @@
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.wrap { max-width: 860px; margin: 0 auto; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
@@ -142,6 +142,183 @@ h1 {
|
||||
#status.hint { color: var(--text-muted); }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* ======================= RÉSULTAT ======================= */
|
||||
#result { display: none; }
|
||||
#setup.hidden { display: none; }
|
||||
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#participantsBanner .pb-meta {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
text-align: right;
|
||||
opacity: 0.92;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
|
||||
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--amber); transition: width 0.4s ease; }
|
||||
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
|
||||
|
||||
/* CARROUSEL */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; white-space: nowrap; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.ko { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .badge.miss { background: rgba(0,0,0,0.06); color: var(--text-muted); }
|
||||
.qcard .qtext { flex: 1; min-width: 0; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
|
||||
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.qcard .qanswer.empty { color: var(--text-muted); font-style: italic; }
|
||||
.qcard .qexpected { font-size: 12.5px; color: var(--text-muted); margin-top: 4px; }
|
||||
.qcard .qexpected strong { color: var(--teal); }
|
||||
|
||||
/* ANNEXES (TP3 …) */
|
||||
.annex {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.annex h2 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.annex .kv { font-size: 13.5px; margin-bottom: 6px; }
|
||||
.annex .kv span { color: var(--text-muted); }
|
||||
.table-scroll { overflow-x: auto; margin-top: 14px; }
|
||||
.annex table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 520px; }
|
||||
.annex th, .annex td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.annex th { background: var(--amber-light); font-family: 'Syne', sans-serif; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -158,56 +335,85 @@ h1 {
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">Certibiocide — Correction des rendus</div>
|
||||
<h1>Corriger un exercice</h1>
|
||||
<p class="lead">Sélectionnez l'exercice concerné, puis déposez le fichier PDF exporté par le groupe. La lecture du rendu s'ouvre automatiquement.</p>
|
||||
<div id="setup">
|
||||
<div class="eyebrow">Certibiocide — Correction des rendus</div>
|
||||
<h1>Corriger un exercice</h1>
|
||||
<p class="lead">Sélectionnez l'exercice concerné, puis déposez le fichier PDF exporté par le groupe. Les réponses s'affichent directement ici, exercice par exercice.</p>
|
||||
|
||||
<p class="step-label"><span class="step-num">1</span> Choisissez l'exercice à corriger</p>
|
||||
<div class="exo-grid" id="exoGrid">
|
||||
<div class="exo-option">
|
||||
<input type="radio" name="exo" id="exo-biocid" value="biocid">
|
||||
<label for="exo-biocid">
|
||||
<span class="exo-icon">🧪</span>
|
||||
<span class="exo-text">
|
||||
<span class="exo-name">Plateforme BioCID</span>
|
||||
<span class="exo-sub">Recherche de produits biocides</span>
|
||||
</span>
|
||||
<span class="exo-check"></span>
|
||||
</label>
|
||||
<p class="step-label"><span class="step-num">1</span> Choisissez l'exercice à corriger</p>
|
||||
<div class="exo-grid" id="exoGrid">
|
||||
<div class="exo-option">
|
||||
<input type="radio" name="exo" id="exo-biocid" value="biocid">
|
||||
<label for="exo-biocid">
|
||||
<span class="exo-icon">🧪</span>
|
||||
<span class="exo-text">
|
||||
<span class="exo-name">Plateforme BioCID</span>
|
||||
<span class="exo-sub">Recherche de produits biocides</span>
|
||||
</span>
|
||||
<span class="exo-check"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="exo-option">
|
||||
<input type="radio" name="exo" id="exo-fds" value="fds">
|
||||
<label for="exo-fds">
|
||||
<span class="exo-icon">📋</span>
|
||||
<span class="exo-text">
|
||||
<span class="exo-name">FDS, FT & Étiquettes</span>
|
||||
<span class="exo-sub">Lecture documentaire</span>
|
||||
</span>
|
||||
<span class="exo-check"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="exo-option">
|
||||
<input type="radio" name="exo" id="exo-tp" value="tp">
|
||||
<label for="exo-tp">
|
||||
<span class="exo-icon">📚</span>
|
||||
<span class="exo-text">
|
||||
<span class="exo-name">TP Nuisibles final</span>
|
||||
<span class="exo-sub">Étude de cas de synthèse</span>
|
||||
</span>
|
||||
<span class="exo-check"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exo-option">
|
||||
<input type="radio" name="exo" id="exo-fds" value="fds">
|
||||
<label for="exo-fds">
|
||||
<span class="exo-icon">📋</span>
|
||||
<span class="exo-text">
|
||||
<span class="exo-name">FDS, FT & Étiquettes</span>
|
||||
<span class="exo-sub">Lecture documentaire</span>
|
||||
</span>
|
||||
<span class="exo-check"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="exo-option">
|
||||
<input type="radio" name="exo" id="exo-tp" value="tp">
|
||||
<label for="exo-tp">
|
||||
<span class="exo-icon">📚</span>
|
||||
<span class="exo-text">
|
||||
<span class="exo-name">TP Nuisibles final</span>
|
||||
<span class="exo-sub">Étude de cas de synthèse</span>
|
||||
</span>
|
||||
<span class="exo-check"></span>
|
||||
|
||||
<p class="step-label"><span class="step-num">2</span> Déposez le rendu PDF</p>
|
||||
<div id="dropzone" class="disabled">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du groupe ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis l'exercice</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status" class="hint">Sélectionnez d'abord un exercice ci-dessus.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="step-label"><span class="step-num">2</span> Déposez le rendu PDF</p>
|
||||
<div id="dropzone" class="disabled">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du groupe ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis l'exercice</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status" class="hint">Sélectionnez d'abord un exercice ci-dessus.</div>
|
||||
<div id="result">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction d'exercice</div>
|
||||
<h1 id="resultTitle"></h1>
|
||||
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span><strong id="progressCount"></strong> <span id="progressLabel"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentGroup - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentGroup + 1)">→</button>
|
||||
</div>
|
||||
<div id="groupPanel"></div>
|
||||
</div>
|
||||
|
||||
<div id="annexes"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -216,16 +422,481 @@ h1 {
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
// Chaque exercice pointe vers sa page de correction dédiée (parseur spécifique).
|
||||
const CORRECTORS = {
|
||||
biocid: "correction-biocid.html",
|
||||
fds: "correction-fds.html",
|
||||
tp: "correction-tp-nuisibles.html",
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
/* ======================= OUTILS COMMUNS ======================= */
|
||||
|
||||
const COLOR_MAP = {
|
||||
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
|
||||
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
purple: { bg: "var(--purple-light)", fg: "var(--purple)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
function isEmpty(v) {
|
||||
if (!v) return true;
|
||||
const t = v.trim();
|
||||
return t === "" || /^\(?sans réponse\)?$/i.test(t) || t === "—";
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels || []) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
// Aplatit le texte et retire les en-têtes/pieds d'impression du navigateur.
|
||||
function flatten(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/\s+/g, " ").trim();
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
|
||||
clean = clean.replace(/Page\s*\d{1,3}\s*sur\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank/gi, " ");
|
||||
clean = clean.replace(/file:\/\/\S+/gi, " ");
|
||||
return clean.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Sépare "Groupe 3 — Alice Bob" en { groupe, participants }
|
||||
function splitGroupe(nom) {
|
||||
const t = (nom || "").trim();
|
||||
const m = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(t);
|
||||
if (m) return { groupe: "Groupe " + m[1], participants: m[2].trim() };
|
||||
return { groupe: "", participants: t };
|
||||
}
|
||||
|
||||
function readMeta(clean, lastKey) {
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", [lastKey]),
|
||||
};
|
||||
Object.assign(meta, splitGroupe(meta.nom));
|
||||
return meta;
|
||||
}
|
||||
|
||||
/* ======================= EXERCICE 1 — BIOCID ======================= */
|
||||
|
||||
const BIOCID_GROUPS = [
|
||||
{ title: "Exercice 1 — Recherche par nom", icon: "🔎", color: "amber", items: [
|
||||
"Ex1 — Produit : type, substance active, fabricant",
|
||||
"Ex1 — Date d'autorisation",
|
||||
"Ex1 — Conditions d'utilisation",
|
||||
]},
|
||||
{ title: "Exercice 2 — Recherche par type", icon: "🗂️", color: "blue", items: [
|
||||
"Ex2 — Nombre de produits dans la catégorie",
|
||||
"Ex2 — Comparaison entre catégories",
|
||||
]},
|
||||
{ title: "Exercice 3 — Recherche par substance", icon: "🧪", color: "teal", items: [
|
||||
"Ex3 — 3 produits contenant la substance",
|
||||
"Ex3 — Formulations et concentrations comparées",
|
||||
]},
|
||||
{ title: "Exercice 4 — Cas de synthèse", icon: "📞", color: "coral", items: [
|
||||
"Ex4 — 3 produits applicables",
|
||||
"Ex4 — Fiche de chaque produit",
|
||||
"Ex4 — Argumentaire (+ bonus FDS)",
|
||||
]},
|
||||
];
|
||||
|
||||
function parseBiocid(raw) {
|
||||
let clean = flatten(raw);
|
||||
clean = clean.replace(/Exercices BioCID\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = readMeta(clean, "Réponses aux exercices");
|
||||
const startIdx = clean.search(/Réponses aux exercices/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx) : clean;
|
||||
|
||||
const all = BIOCID_GROUPS.flatMap(g => g.items);
|
||||
const answers = {};
|
||||
all.forEach((label, i) => {
|
||||
answers[label] = captureBetween(block, label, all[i + 1] ? [all[i + 1]] : []);
|
||||
});
|
||||
|
||||
const groups = BIOCID_GROUPS.map(g => ({
|
||||
title: g.title, icon: g.icon, color: g.color,
|
||||
items: g.items.map(label => {
|
||||
const value = answers[label] || "";
|
||||
return {
|
||||
label: label.replace(/^Ex\d+\s*[—-]\s*/, ""),
|
||||
answer: value,
|
||||
state: isEmpty(value) ? "miss" : "ok",
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return { title: "Plateforme BioCID — Lecture du rendu", meta, groups, scored: false };
|
||||
}
|
||||
|
||||
/* ======================= EXERCICE 2 — FDS / FT / ÉTIQUETTE ======================= */
|
||||
|
||||
const fdsQuestions = [
|
||||
{ text: "Quel est le nom du produit et qui est le fabricant ?", answer: "1" },
|
||||
{ text: "Quels sont les dangers du produit ?", answer: "2" },
|
||||
{ text: "Quelles sont les molécules actives et leurs concentrations ?", answer: "3" },
|
||||
{ text: "Quels gestes adopter en premiers secours ?", answer: "4" },
|
||||
{ text: "En cas d'incendie, quels sont les moyens d'extinction conseillés ?", answer: "5" },
|
||||
{ text: "Que faire en cas de dispersion accidentelle ?", answer: "6" },
|
||||
{ text: "Dans quelles conditions stocker mon produit ?", answer: "7" },
|
||||
{ text: "Quels équipements de protection individuelle (EPI) dois-je porter ?", answer: "8" },
|
||||
{ text: "Quel est le pH de ce produit ?", answer: "9" },
|
||||
{ text: "Le produit est-il stable dans des conditions normales ?", answer: "10" },
|
||||
{ text: "Quels sont les effets potentiels sur la santé ?", answer: "11" },
|
||||
{ text: "Quel est le degré de toxicité pour les poissons ? Potentiel de bioaccumulation ?", answer: "12" },
|
||||
{ text: "Comment éliminer le produit ?", answer: "13" },
|
||||
{ text: "Est-ce que mon produit est classé dangereux pour le transport ?", answer: "14" },
|
||||
{ text: "En cas d'ingestion du produit, que faire ?", answer: "4,11" },
|
||||
{ text: "Où trouver le mode d'emploi du produit ?", answer: "absent" },
|
||||
];
|
||||
const ftQuestions = [
|
||||
{ text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
|
||||
{ text: "Sur quelles matières ce produit est-il compatible ?", answer: "Description" },
|
||||
{ text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" },
|
||||
{ text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
|
||||
{ text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" },
|
||||
];
|
||||
const etiquetteQuestions = [
|
||||
{ text: "Quel est le nom du produit et le nom de l'entreprise ?", answer: "Identité" },
|
||||
{ text: "Quels pictogrammes de danger sont affichés ?", answer: "Pictogrammes" },
|
||||
{ text: "Quels sont les mots d'avertissement (« Attention » ou « Danger ») ?", answer: "Mots d'avertissement" },
|
||||
{ text: "Quelles sont les mentions de danger (H-statements) identifiées ?", answer: "Mentions de danger" },
|
||||
{ text: "Quels équipements de protection individuelle (EPI) sont recommandés ?", answer: "Conseils de prudence" },
|
||||
{ text: "Quels premiers secours sont indiqués ?", answer: "Conseils de prudence" },
|
||||
{ text: "Comment éliminer l'emballage après utilisation ?", answer: "Conseils de prudence" },
|
||||
{ text: "Quel est le contenu net du produit ?", answer: "Contenu net" },
|
||||
{ text: "Y a-t-il un numéro d'appel d'urgence mentionné ?", answer: "Numéro d'urgence" },
|
||||
{ text: "Quelles consignes de stockage sont affichées ?", answer: "Conseils de prudence" },
|
||||
];
|
||||
|
||||
const FDS_PARTS = [
|
||||
{ prefix: "P1", title: "Partie 1 — FDS", icon: "📄", color: "amber", questions: fdsQuestions, kind: "fds" },
|
||||
{ prefix: "P2", title: "Partie 2 — Fiche Technique", icon: "📋", color: "coral", questions: ftQuestions, kind: "text" },
|
||||
{ prefix: "P3", title: "Partie 3 — Étiquette", icon: "🏷️", color: "teal", questions: etiquetteQuestions, kind: "text" },
|
||||
];
|
||||
|
||||
// Récupère « Réponse du groupe : X — Attendu : … » pour un marqueur [Px-Qn]
|
||||
function extractSelected(fullText, marker, nextMarker) {
|
||||
const seg = captureBetween(fullText, marker, nextMarker ? [nextMarker] : []);
|
||||
const m = /Réponse du groupe\s*:?\s*([\s\S]*?)\s*[—–-]*\s*Attendu\s*:/i.exec(seg);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
function normFdsToken(sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return null;
|
||||
if (/^absent$/i.test(sel.trim())) return "absent";
|
||||
const m = /(\d+)/.exec(sel);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
function scoreFds(q, sel) {
|
||||
const tok = normFdsToken(sel);
|
||||
if (tok === null) return "miss";
|
||||
return q.answer.split(",").includes(tok) ? "ok" : "ko";
|
||||
}
|
||||
function scoreText(q, sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return "miss";
|
||||
const norm = /^absent$/i.test(sel.trim()) ? "absent" : sel.trim();
|
||||
return norm.toLowerCase() === q.answer.toLowerCase() ? "ok" : "ko";
|
||||
}
|
||||
function fdsExpectedLabel(part, q) {
|
||||
if (part.kind !== "fds") return q.answer;
|
||||
const parts = q.answer.split(",");
|
||||
if (parts.length > 1) return "Rubrique " + parts.join(" ou ");
|
||||
return parts[0] === "absent" ? "Absent de la FDS" : "Rubrique " + parts[0];
|
||||
}
|
||||
|
||||
function parseFds(raw) {
|
||||
let clean = flatten(raw);
|
||||
clean = clean.replace(/FDS, FT (&|&) Étiquettes\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = readMeta(clean, "Score global");
|
||||
|
||||
const markers = [];
|
||||
FDS_PARTS.forEach(p => p.questions.forEach((q, i) => markers.push("[" + p.prefix + "-Q" + (i + 1) + "]")));
|
||||
|
||||
let mIdx = 0;
|
||||
const groups = FDS_PARTS.map(part => ({
|
||||
title: part.title, icon: part.icon, color: part.color,
|
||||
items: part.questions.map((q, i) => {
|
||||
const marker = "[" + part.prefix + "-Q" + (i + 1) + "]";
|
||||
const sel = extractSelected(clean, marker, markers[mIdx + 1]);
|
||||
mIdx++;
|
||||
const state = part.kind === "fds" ? scoreFds(q, sel) : scoreText(q, sel);
|
||||
return {
|
||||
label: q.text,
|
||||
answer: sel,
|
||||
state,
|
||||
expected: fdsExpectedLabel(part, q),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return { title: "FDS, FT & Étiquettes — Correction du rendu", meta, groups, scored: true };
|
||||
}
|
||||
|
||||
/* ======================= EXERCICE 3 — TP NUISIBLES ======================= */
|
||||
|
||||
const TP_GROUPS = [
|
||||
{ title: "Premier contact & diagnostic", icon: "🗣️", color: "purple", items: [
|
||||
"Questions posées au client lors du premier contact",
|
||||
"Conseils donnés en premier lieu",
|
||||
"Actions de prévention menées sur le site",
|
||||
"Éléments observés / preuve de présence du nuisible",
|
||||
]},
|
||||
{ title: "Solutions envisagées", icon: "🧰", color: "blue", items: [
|
||||
"Solutions alternatives non-biocides",
|
||||
"Solutions biocides mises en place",
|
||||
"Type de produit (TP) et mode de traitement",
|
||||
"Suivi mis en place et durée",
|
||||
"Gestion des déchets",
|
||||
]},
|
||||
{ title: "Produit & traçabilité", icon: "🧪", color: "teal", items: [
|
||||
"FDS trouvée (éléments principaux)",
|
||||
"Nom du produit et fabricant/fournisseur",
|
||||
"Numéro AMM",
|
||||
]},
|
||||
{ title: "Sécurité & santé", icon: "🛡️", color: "coral", items: [
|
||||
"Risques pour la santé",
|
||||
"EPI à porter",
|
||||
"Risques environnement / dispersion accidentelle",
|
||||
"DL50 substance(s) active(s)",
|
||||
"Gestes de premiers secours",
|
||||
]},
|
||||
];
|
||||
|
||||
const TP_KNOWN_STEPS = [
|
||||
"remplir la poudreuse",
|
||||
"sécurisation du site",
|
||||
"application traitements",
|
||||
"rangement du matériel. purger la perche",
|
||||
"retrait du nid",
|
||||
"gestion des déchets. élimination par des filiaires spé.",
|
||||
];
|
||||
|
||||
function parseTpRiskTable(tp3Zone) {
|
||||
const rows = [];
|
||||
const tableMatch = /Étapes\s*Risques\s*Mesures de prévention([\s\S]*?)(Réalisée par|$)/i.exec(tp3Zone);
|
||||
if (!tableMatch) return rows;
|
||||
const raw = tableMatch[1].trim();
|
||||
if (!raw) return rows;
|
||||
|
||||
const positions = [];
|
||||
TP_KNOWN_STEPS.forEach(step => {
|
||||
const idx = raw.toLowerCase().indexOf(step.toLowerCase());
|
||||
if (idx >= 0) positions.push({ step, idx });
|
||||
});
|
||||
positions.sort((a, b) => a.idx - b.idx);
|
||||
|
||||
if (positions.length === 0) {
|
||||
rows.push({ etape: raw, risque: "—", mesure: "—" });
|
||||
return rows;
|
||||
}
|
||||
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const from = positions[i].idx + positions[i].step.length;
|
||||
const to = i + 1 < positions.length ? positions[i + 1].idx : raw.length;
|
||||
const rest = raw.slice(from, to).trim();
|
||||
const measureRx = /(gants\s*\/\s*masques\s*\/\s*lunettes[^.]*|barrieres\.?\s*epi)/i;
|
||||
const mm = measureRx.exec(rest);
|
||||
let risque = rest, mesure = "";
|
||||
if (mm) {
|
||||
risque = rest.slice(0, mm.index).trim();
|
||||
mesure = rest.slice(mm.index).trim();
|
||||
}
|
||||
risque = risque.replace(/^[\s\-–.]+/, "").trim();
|
||||
rows.push({ etape: positions[i].step, risque: risque || "—", mesure: mesure || "—" });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseTp(raw) {
|
||||
let clean = flatten(raw);
|
||||
clean = clean.replace(/Résultats TP\s*[—–-]\s*[^[]{0,80}?(?=Groupe \/ Stagiaire)/gi, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = readMeta(clean, "Cas pratique");
|
||||
meta.cas = captureBetween(clean, "Cas pratique", ["Réponses TP1"]);
|
||||
|
||||
const startIdx = clean.search(/Réponses TP1\s*&?\s*TP2/i);
|
||||
const endIdx = clean.search(/TP3\s*[—–-]\s*Analyse de risques/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx, endIdx >= 0 ? endIdx : clean.length) : clean;
|
||||
|
||||
const all = TP_GROUPS.flatMap(g => g.items);
|
||||
const answers = {};
|
||||
all.forEach((label, i) => {
|
||||
answers[label] = captureBetween(block, label, all[i + 1] ? [all[i + 1]] : []);
|
||||
});
|
||||
|
||||
const groups = TP_GROUPS.map(g => ({
|
||||
title: g.title, icon: g.icon, color: g.color,
|
||||
items: g.items.map(label => ({
|
||||
label,
|
||||
answer: answers[label] || "",
|
||||
state: isEmpty(answers[label]) ? "miss" : "ok",
|
||||
})),
|
||||
}));
|
||||
|
||||
const tp3Zone = endIdx >= 0 ? clean.slice(endIdx) : "";
|
||||
const tp3 = {
|
||||
intitule: captureBetween(tp3Zone, "Intitulé", ["Description"]),
|
||||
description: captureBetween(tp3Zone, "Description", ["Lieu"]),
|
||||
lieu: captureBetween(tp3Zone, "Lieu", ["Début prévu le"]),
|
||||
debut: captureBetween(tp3Zone, "Début prévu le", ["Étapes"]),
|
||||
};
|
||||
const riskRows = parseTpRiskTable(tp3Zone);
|
||||
|
||||
const mReal = clean.match(/Réalisée par\s*:\s*([^—\n]*)/i);
|
||||
const mVerif = clean.match(/Vérifiée par\s*:\s*([^—\n]*)/i);
|
||||
const mMaj = clean.match(/Mise à jour par\s*:\s*([^—\n]*)/i);
|
||||
|
||||
const annexes = [];
|
||||
if (tp3.intitule || riskRows.length) {
|
||||
annexes.push({
|
||||
title: "TP3 — Analyse de risques",
|
||||
html: `
|
||||
<div class="kv"><span>Intitulé :</span> ${escapeHtml(tp3.intitule) || "—"}</div>
|
||||
<div class="kv"><span>Description :</span> ${escapeHtml(tp3.description) || "—"}</div>
|
||||
<div class="kv"><span>Lieu :</span> ${escapeHtml(tp3.lieu) || "—"}</div>
|
||||
<div class="kv"><span>Début prévu le :</span> ${escapeHtml(tp3.debut) || "—"}</div>
|
||||
${riskRows.length ? `
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<tr><th>Étapes</th><th>Risques</th><th>Mesures de prévention</th></tr>
|
||||
${riskRows.map(r => `<tr><td>${escapeHtml(r.etape)}</td><td>${escapeHtml(r.risque)}</td><td>${escapeHtml(r.mesure)}</td></tr>`).join("")}
|
||||
</table>
|
||||
</div>` : ""}
|
||||
<div class="kv" style="margin-top:14px;">
|
||||
<span>Réalisée par :</span> ${escapeHtml(mReal ? mReal[1].trim() : "") || "—"} ·
|
||||
<span>Vérifiée par :</span> ${escapeHtml(mVerif ? mVerif[1].trim() : "") || "—"} ·
|
||||
<span>Mise à jour par :</span> ${escapeHtml(mMaj ? mMaj[1].trim() : "") || "—"}
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
return { title: "TP Nuisibles — Lecture du rendu", meta, groups, scored: false, annexes };
|
||||
}
|
||||
|
||||
/* ======================= REGISTRE DES EXERCICES ======================= */
|
||||
|
||||
const EXERCISES = {
|
||||
biocid: { parse: parseBiocid, sentinel: /Réponses aux exercices|Ex1\s*[—-]/i, name: "BioCID" },
|
||||
fds: { parse: parseFds, sentinel: /\[P1-Q1\]|Partie 1\s*[—-]\s*FDS/i, name: "FDS, FT & Étiquettes" },
|
||||
tp: { parse: parseTp, sentinel: /Réponses TP1|TP Nuisibles/i, name: "TP Nuisibles" },
|
||||
};
|
||||
|
||||
/* ======================= RENDU ======================= */
|
||||
|
||||
let currentData = null;
|
||||
let currentGroup = 0;
|
||||
|
||||
function renderGroupPanel() {
|
||||
const group = currentData.groups[currentGroup];
|
||||
const colors = COLOR_MAP[group.color] || COLOR_MAP.amber;
|
||||
const good = group.items.filter(it => it.state === "ok").length;
|
||||
const countLabel = currentData.scored
|
||||
? `${good}/${group.items.length} corrects`
|
||||
: `${good}/${group.items.length} renseignées`;
|
||||
|
||||
document.getElementById("groupPanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${group.icon}</div>
|
||||
<div class="theme-title">${escapeHtml(group.title)}</div>
|
||||
<div class="theme-count">${countLabel}</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${group.items.map(it => {
|
||||
const mark = it.state === "ok" ? "✓" : (it.state === "ko" ? "✗" : "!");
|
||||
const empty = it.state === "miss";
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${it.state}">${mark}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">${escapeHtml(it.label)}</div>
|
||||
<div class="qanswer ${empty ? "empty" : ""}">${empty ? "Sans réponse" : escapeHtml(it.answer)}</div>
|
||||
${it.expected && it.state !== "ok"
|
||||
? `<div class="qexpected">Attendu : <strong>${escapeHtml(it.expected)}</strong></div>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentGroup === 0;
|
||||
document.getElementById("nextBtn").disabled = currentGroup === currentData.groups.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentGroup));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (!currentData || idx < 0 || idx >= currentData.groups.length) return;
|
||||
currentGroup = idx;
|
||||
renderGroupPanel();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentData = data;
|
||||
currentGroup = 0;
|
||||
|
||||
document.getElementById("resultTitle").textContent = data.title;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
const noms = data.meta.participants || data.meta.nom;
|
||||
if (noms) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(noms)}</div>
|
||||
</div>
|
||||
<div class="pb-meta">
|
||||
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
|
||||
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) + "<br>" : ""}
|
||||
${data.meta.cas ? "Cas : " + escapeHtml(data.meta.cas) : ""}
|
||||
</div>`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
const allItems = data.groups.flatMap(g => g.items);
|
||||
const good = allItems.filter(it => it.state === "ok").length;
|
||||
document.getElementById("progressFill").style.width = (good / allItems.length * 100) + "%";
|
||||
document.getElementById("progressCount").textContent = `${good} / ${allItems.length}`;
|
||||
document.getElementById("progressLabel").textContent = data.scored ? "réponses correctes" : "questions renseignées";
|
||||
|
||||
document.getElementById("dots").innerHTML = data.groups.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? " active" : ""}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
document.getElementById("annexes").innerHTML = (data.annexes || []).map(a =>
|
||||
`<div class="annex"><h2>${escapeHtml(a.title)}</h2>${a.html}</div>`
|
||||
).join("");
|
||||
|
||||
renderGroupPanel();
|
||||
|
||||
document.getElementById("setup").classList.add("hidden");
|
||||
document.getElementById("result").style.display = "block";
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
/* ======================= CHARGEMENT DU PDF ======================= */
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const status = document.getElementById("status");
|
||||
const statusEl = document.getElementById("status");
|
||||
const filePicker = document.getElementById("filePicker");
|
||||
|
||||
function selectedExo() {
|
||||
@@ -234,23 +905,83 @@ function selectedExo() {
|
||||
}
|
||||
|
||||
function refreshDropzone() {
|
||||
const exo = selectedExo();
|
||||
if (exo) {
|
||||
if (selectedExo()) {
|
||||
dropzone.classList.remove("disabled");
|
||||
status.className = "hint";
|
||||
status.textContent = "Prêt à recevoir le PDF.";
|
||||
statusEl.className = "hint";
|
||||
statusEl.textContent = "Prêt à recevoir le PDF.";
|
||||
} else {
|
||||
dropzone.classList.add("disabled");
|
||||
status.className = "hint";
|
||||
status.textContent = "Sélectionnez d'abord un exercice ci-dessus.";
|
||||
statusEl.className = "hint";
|
||||
statusEl.textContent = "Sélectionnez d'abord un exercice ci-dessus.";
|
||||
}
|
||||
}
|
||||
|
||||
async function readPdfText(file) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
return fullText;
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
const exo = selectedExo();
|
||||
if (!exo) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Choisissez un exercice avant de déposer un fichier.";
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
if (file.type !== "application/pdf" && !/\.pdf$/i.test(file.name)) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Le fichier doit être un PDF.";
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
|
||||
try {
|
||||
const text = await readPdfText(file);
|
||||
const config = EXERCISES[exo];
|
||||
|
||||
if (!config.sentinel.test(text)) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = `Ce PDF ne ressemble pas à un rendu « ${config.name} ». Vérifiez l'exercice sélectionné.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = config.parse(text);
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
renderResult(data);
|
||||
statusEl.className = "hint";
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export d'exercice.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
currentData = null;
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("setup").classList.remove("hidden");
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("annexes").innerHTML = "";
|
||||
filePicker.value = "";
|
||||
refreshDropzone();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
// Préselection via ?exo=biocid|fds|tp
|
||||
(function preselect() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const exo = params.get("exo");
|
||||
if (exo && CORRECTORS[exo]) {
|
||||
const exo = new URLSearchParams(location.search).get("exo");
|
||||
if (exo && EXERCISES[exo]) {
|
||||
const input = document.getElementById("exo-" + exo);
|
||||
if (input) input.checked = true;
|
||||
}
|
||||
@@ -261,44 +992,6 @@ document.querySelectorAll('input[name="exo"]').forEach(r =>
|
||||
);
|
||||
refreshDropzone();
|
||||
|
||||
function handleFile(file) {
|
||||
const exo = selectedExo();
|
||||
if (!exo) {
|
||||
status.className = "error";
|
||||
status.textContent = "Choisissez un exercice avant de déposer un fichier.";
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
if (file.type !== "application/pdf" && !/\.pdf$/i.test(file.name)) {
|
||||
status.className = "error";
|
||||
status.textContent = "Le fichier doit être un PDF.";
|
||||
return;
|
||||
}
|
||||
|
||||
status.className = "loading";
|
||||
status.textContent = "Ouverture du correcteur…";
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
// On transmet le PDF à la page de correction dédiée via localStorage
|
||||
localStorage.setItem("correctionFile", reader.result);
|
||||
localStorage.setItem("correctionFileName", file.name);
|
||||
localStorage.setItem("correctionExo", exo);
|
||||
window.location.href = CORRECTORS[exo] + "?auto=1";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
status.className = "error";
|
||||
status.textContent = "Fichier trop volumineux pour être transmis. Ouvrez la page de correction dédiée.";
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
status.className = "error";
|
||||
status.textContent = "Impossible de lire le fichier.";
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
dropzone.addEventListener("dragover", e => {
|
||||
if (dropzone.classList.contains("disabled")) return;
|
||||
e.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user