Skip to content

Commit 63ebfa2

Browse files
JeffDiginamicclaude
andcommitted
feat(pdf): move <details> blocks to an annex, keep tables whole
Synced from Cours---Introduction---Culture-Geek. - A closed <details> block prints as its summary only, so answers and corrections vanished from the course PDF. Each block now moves, unfolded, to an "Annexes" section grouped by chapter, and is replaced in the text by a numbered link, with a link back from the annex. - Tables no longer split across pages: a table that does not fit moves whole to the next page, and headings stay with what follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2caf858 commit 63ebfa2

1 file changed

Lines changed: 192 additions & 0 deletions

File tree

‎.github/workflows/documents.yml‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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

Comments
 (0)