@@ -8,6 +8,8 @@ name: Documents
88# un numéro (`01-…`, `1-…`) ou par une lettre (`A-…`, `B-…`), pris dans l'ordre.
99# Chaque chapitre est précédé d'une page d'intercalaire portant son titre de
1010# premier niveau. S'il n'y a aucun chapitre, le PDF ne contient que le Readme.
11+ # Les blocs repliables (`<details>`) sont déplacés, dépliés, dans une annexe
12+ # qui ferme le document ; un renvoi numéroté les remplace dans le texte.
1113#
1214# Les diapositives (PDF, PPTX, et leurs variantes `-white` sans le style
1315# sombre) ne sont produites que si `slides.md` existe à la racine. Sinon ces
@@ -536,6 +538,172 @@ jobs:
536538 exit 1
537539 fi
538540
541+ # Un bloc `<details>` se replie dans le navigateur, mais sur papier il n'y
542+ # a rien à déplier : Chrome imprime le seul résumé (« Voir la réponse »),
543+ # et la réponse disparaît du PDF. Chaque bloc est donc déplacé, déplié,
544+ # dans une annexe en fin de document. À sa place, un renvoi cliquable
545+ # porte son numéro (« Voir la réponse → annexe 3 »), de quoi le retrouver
546+ # aussi sur une version imprimée. Chaque entrée d'annexe porte le titre de
547+ # la section d'où elle vient et renvoie au texte ; les entrées sont
548+ # regroupées sous le titre de leur chapitre, sans quoi les « Vous
549+ # faites » ou « Exercice » de chaque chapitre ne se distingueraient plus.
550+ #
551+ # Seuls les blocs qui ouvrent leur ligne sont déplacés, les blocs
552+ # imbriqués restant avec leur parent. Tout `<details>` resté en place
553+ # reçoit l'attribut `open` : il s'imprime au moins déplié. Les blocs de
554+ # code ne sont pas touchés — un cours de HTML peut y citer `<details>`.
555+ #
556+ # L'étape passe avant les crédits, qui doivent rester la dernière section.
557+ - name: Déplacer les blocs repliables en annexe
558+ run: |
559+ cat > build/details-to-annex.js <<'JS'
560+ const fs = require('fs');
561+
562+ const [, , mdPath] = process.argv;
563+
564+ const FENCE = /^\s*(```|~~~)/;
565+ const count = (re, s) => (s.match(re) || []).length;
566+ const OPEN = /<details\b/gi;
567+ const CLOSE = /<\/details\s*>/gi;
568+
569+ const unescapeHtml = (s) =>
570+ s.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
571+
572+ // Tout <details> hors bloc de code s'imprime déplié.
573+ const forceOpen = (lines) => {
574+ let fenced = false;
575+ return lines.map((line) => {
576+ if (FENCE.test(line)) {
577+ fenced = !fenced;
578+ return line;
579+ }
580+ return fenced ? line : line.replace(/<details\b(?![^>]*\bopen\b)/gi, '<details open');
581+ });
582+ };
583+
584+ const body = [];
585+ const annexes = [];
586+ let inFence = false;
587+ let chapter = '';
588+ let section = '';
589+ let block = null;
590+
591+ // Le bloc complet est dans `block.lines` : il part en annexe, un
592+ // renvoi prend sa place.
593+ const move = () => {
594+ const n = annexes.length + 1;
595+ let summary = 'Voir le détail';
596+
597+ let raw = block.lines.join('\n');
598+ raw = raw.replace(/<details\b[^>]*>/i, '');
599+ // Le résumé du bloc suit directement la balise ouvrante : un
600+ // <summary> plus loin appartient à un bloc imbriqué.
601+ raw = raw.replace(/^\s*<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/i, (m, text) => {
602+ if (text.trim()) summary = text.trim();
603+ return '';
604+ });
605+ // La dernière balise fermante est celle du bloc : les précédentes
606+ // appartiennent aux blocs imbriqués.
607+ const end = raw.search(/<\/details\s*>(?![\s\S]*<\/details\s*>)/i);
608+ if (end >= 0) raw = raw.slice(0, end) + raw.slice(end).replace(/<\/details\s*>/i, '');
609+
610+ // Un bloc indenté (dans un élément de liste) est ramené en marge.
611+ const content = raw
612+ .split('\n')
613+ .map((line) => (line.startsWith(block.indent) ? line.slice(block.indent.length) : line))
614+ .join('\n')
615+ .replace(/^\s*\n|\n\s*$/g, '');
616+
617+ annexes.push({ n, summary, chapter: block.chapter, title: block.section || summary, content });
618+ body.push(
619+ `${block.indent}<p class="renvoi-annexe" id="pdf-renvoi-${n}"><a href="#pdf-annexe-${n}">${summary} → annexe ${n}</a></p>`,
620+ '',
621+ );
622+ block = null;
623+ };
624+
625+ for (const line of fs.readFileSync(mdPath, 'utf8').split('\n')) {
626+ if (block) {
627+ block.lines.push(line);
628+ if (FENCE.test(line)) {
629+ block.fenced = !block.fenced;
630+ continue;
631+ }
632+ if (block.fenced) continue;
633+ block.depth += count(OPEN, line) - count(CLOSE, line);
634+ if (block.depth <= 0) move();
635+ continue;
636+ }
637+
638+ if (FENCE.test(line)) inFence = !inFence;
639+ if (inFence || FENCE.test(line)) {
640+ body.push(line);
641+ continue;
642+ }
643+
644+ // Le chapitre courant (dernier intercalaire) et sa section (dernier
645+ // titre). Le Readme, avant tout intercalaire, n'a pas de chapitre.
646+ const heading = line.match(/^#{1,6}\s+(.*?)(\s+#+)?\s*$/);
647+ if (heading) section = heading[1];
648+ const divider = line.match(/<div class="intercalaire"[^>]*><span>(.*)<\/span><\/div>/);
649+ if (divider) {
650+ chapter = unescapeHtml(divider[1]);
651+ section = '';
652+ }
653+
654+ if (/^\s*<details\b/i.test(line)) {
655+ block = {
656+ lines: [line],
657+ depth: count(OPEN, line) - count(CLOSE, line),
658+ indent: line.match(/^\s*/)[0],
659+ chapter,
660+ section,
661+ fenced: false,
662+ };
663+ if (block.depth <= 0) move();
664+ continue;
665+ }
666+
667+ body.push(line);
668+ }
669+
670+ if (block) {
671+ console.log(`::warning::<details> jamais refermé (section « ${block.section || block.chapter} ») : laissé en place.`);
672+ body.push(...block.lines);
673+ }
674+
675+ const out = [...body];
676+ if (annexes.length) {
677+ out.push('', '<div class="intercalaire" id="pdf-annexes"><span>Annexes</span></div>', '');
678+ let current = '';
679+ for (const { n, chapter: from, title, content } of annexes) {
680+ // Un titre de chapitre à chaque changement de chapitre ; ses
681+ // entrées descendent alors d'un niveau.
682+ if (from && from !== current) out.push(`## ${from}`, '');
683+ current = from;
684+ out.push(
685+ `<a id="pdf-annexe-${n}"></a>`,
686+ '',
687+ `${from ? '###' : '##'} Annexe ${n} — ${title}`,
688+ '',
689+ `<p class="retour-annexe"><a href="#pdf-renvoi-${n}">↩ Retour au texte</a></p>`,
690+ '',
691+ content,
692+ '',
693+ );
694+ }
695+ }
696+
697+ fs.writeFileSync(mdPath, forceOpen(out).join('\n'));
698+
699+ console.log(`Blocs <details> déplacés en annexe : ${annexes.length}`);
700+ for (const { n, summary, chapter: from, title } of annexes) {
701+ console.log(` ${n}. ${from ? `${from} › ` : ''}${title} (« ${summary} »)`);
702+ }
703+ JS
704+
705+ node build/details-to-annex.js build/support-de-cours.md
706+
539707 # Les chemins des images locales (`./res/logo.png`) sont écrits depuis la
540708 # racine du dépôt, où vivent le Readme et les chapitres. Le support, lui,
541709 # est assemblé et converti dans `build/`, et md-to-pdf y résout ces
@@ -843,6 +1011,15 @@ jobs:
8431011 line-height: 1.35;
8441012 max-width: 80%;
8451013 }
1014+ /* Le renvoi qui remplace un bloc <details>, et le retour depuis
1015+ l'annexe : discrets, pour ne pas se confondre avec le texte. */
1016+ .renvoi-annexe,
1017+ .retour-annexe {
1018+ font-style: italic;
1019+ }
1020+ .retour-annexe {
1021+ font-size: 0.9em;
1022+ }
8461023 /* Diagrammes mermaid, rendus en SVG à leur taille naturelle : jamais
8471024 plus larges que la colonne, ni plus hauts que la zone imprimable
8481025 (253 mm, marge de sécurité comprise). Une image ne se coupe pas :
@@ -859,6 +1036,21 @@ jobs:
8591036 break-inside: avoid;
8601037 page-break-inside: avoid;
8611038 }
1039+ /* Un tableau ne se coupe pas : s'il ne tient pas en bas de page, il
1040+ passe entier à la suivante. Seul un tableau plus haut qu'une page
1041+ se coupe encore, et alors entre deux lignes, jamais au milieu
1042+ d'une ligne. */
1043+ table,
1044+ tr {
1045+ break-inside: avoid;
1046+ page-break-inside: avoid;
1047+ }
1048+ /* Un titre reste avec ce qui le suit : sans cela, un tableau renvoyé
1049+ à la page suivante laisserait son titre seul en bas de page. */
1050+ h1, h2, h3, h4, h5, h6 {
1051+ break-after: avoid;
1052+ page-break-after: avoid;
1053+ }
8621054 CSS
8631055
8641056 npx --yes "md-to-pdf@${MD_TO_PDF_VERSION}" support-de-cours.md \
0 commit comments