Files
gesthub/docs/build_docx.js
2026-08-21 09:12:02 +02:00

487 lines
17 KiB
JavaScript

// Génère docs/Dossier_technique_GestHub_v2.docx à partir des fichiers
// Markdown dans ce dossier, dans un ordre défini, avec titre, sommaire,
// tableaux, code, et images.
const fs = require("fs");
const path = require("path");
const {
Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow,
TableCell, WidthType, ShadingType, BorderStyle, ImageRun, AlignmentType,
PageBreak, TableOfContents, ExternalHyperlink, LevelFormat, Header, Footer,
PageNumber, NumberFormat,
} = require("docx");
const DOCS_DIR = __dirname;
// -------------------- Markdown -> docx éléments -----------------------------
function parseInlineTokens(text) {
// Découpe en tokens plats {text, bold, code}, gérant **gras** et `code`
const tokens = [];
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
let last = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) tokens.push({ text: text.slice(last, m.index) });
const token = m[0];
if (token.startsWith("**")) {
tokens.push({ text: token.slice(2, -2), bold: true });
} else {
tokens.push({ text: token.slice(1, -1), code: true });
}
last = re.lastIndex;
}
if (last < text.length) tokens.push({ text: text.slice(last) });
if (tokens.length === 0) tokens.push({ text: "" });
return tokens;
}
function tokensToRuns(tokens, overrides = {}) {
return tokens.map((t) => {
if (t.code) {
return new TextRun({
text: t.text, font: "Consolas", size: overrides.size || 19,
color: overrides.color || "1f2937", bold: overrides.bold || t.bold,
});
}
return new TextRun({
text: t.text, size: overrides.size || 21,
bold: overrides.bold !== undefined ? overrides.bold : t.bold,
color: overrides.color, italics: overrides.italics,
});
});
}
function parseInlineBold(text, overrides = {}) {
return tokensToRuns(parseInlineTokens(text), overrides);
}
function makeTableCell(text, { header = false, width } = {}) {
return new TableCell({
width: { size: width, type: WidthType.DXA },
shading: header ? { type: ShadingType.CLEAR, color: "auto", fill: "1f2937" } : undefined,
margins: { top: 60, bottom: 60, left: 100, right: 100 },
children: [
new Paragraph({
children: header
? tokensToRuns(parseInlineTokens(text), { bold: true, color: "FFFFFF", size: 19 })
: parseInlineBold(text),
}),
],
});
}
function parseTable(lines) {
// lines: array of markdown table lines (with leading |)
const rows = lines.filter((l) => !/^\|[\s-:|]+\|$/.test(l.trim()));
const cellsPerRow = rows.map((l) =>
l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim())
);
const nCols = cellsPerRow[0].length;
const tableWidthDxa = 9350;
const colWidth = Math.floor(tableWidthDxa / nCols);
const colWidths = new Array(nCols).fill(colWidth);
const trows = cellsPerRow.map((cells, ri) =>
new TableRow({
tableHeader: ri === 0,
children: cells.map((c, ci) => makeTableCell(c, { header: ri === 0, width: colWidths[ci] })),
})
);
return new Table({
width: { size: tableWidthDxa, type: WidthType.DXA },
columnWidths: colWidths,
rows: trows,
borders: {
top: { style: BorderStyle.SINGLE, size: 2, color: "9CA3AF" },
bottom: { style: BorderStyle.SINGLE, size: 2, color: "9CA3AF" },
left: { style: BorderStyle.SINGLE, size: 2, color: "9CA3AF" },
right: { style: BorderStyle.SINGLE, size: 2, color: "9CA3AF" },
insideHorizontal: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
insideVertical: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
},
});
}
function imageParagraph(relPath, caption) {
const fullPath = path.join(DOCS_DIR, relPath);
const buffer = fs.readFileSync(fullPath);
// dimension réelle -> on limite la largeur à 560pt (~14.8cm) en conservant le ratio
const { imageSize } = require("image-size");
const dim = imageSize(new Uint8Array(buffer));
const maxW = 560;
const ratio = Math.min(1, maxW / dim.width);
const w = Math.round(dim.width * ratio);
const h = Math.round(dim.height * ratio);
const els = [
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 200, after: 80 },
children: [
new ImageRun({ data: buffer, transformation: { width: w, height: h }, type: path.extname(fullPath).slice(1) }),
],
}),
];
if (caption) {
els.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { after: 200 },
children: [new TextRun({ text: caption, italics: true, size: 18, color: "6B7280" })],
})
);
}
return els;
}
function markdownToElements(md) {
const lines = md.split("\n");
const elements = [];
let i = 0;
let inCode = false;
let codeLines = [];
let paraBuffer = [];
function flushParagraph() {
if (paraBuffer.length) {
elements.push(new Paragraph({ children: parseInlineBold(paraBuffer.join(" ")), spacing: { after: 120 } }));
paraBuffer = [];
}
}
while (i < lines.length) {
const line = lines[i];
if (line.trim().startsWith("```")) {
flushParagraph();
if (!inCode) {
inCode = true;
codeLines = [];
} else {
inCode = false;
elements.push(
new Table({
width: { size: 9350, type: WidthType.DXA },
columnWidths: [9350],
rows: [
new TableRow({
children: [
new TableCell({
width: { size: 9350, type: WidthType.DXA },
shading: { type: ShadingType.CLEAR, color: "auto", fill: "F3F4F6" },
margins: { top: 120, bottom: 120, left: 160, right: 160 },
children: codeLines.length
? codeLines.map(
(cl) =>
new Paragraph({
children: [new TextRun({ text: cl.length ? cl : " ", font: "Consolas", size: 18 })],
})
)
: [new Paragraph({ children: [new TextRun({ text: " ", size: 18 })] })],
}),
],
}),
],
borders: {
top: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
bottom: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
left: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
right: { style: BorderStyle.SINGLE, size: 2, color: "D1D5DB" },
insideHorizontal: { style: BorderStyle.NONE },
insideVertical: { style: BorderStyle.NONE },
},
})
);
elements.push(new Paragraph({ text: "", spacing: { after: 120 } }));
}
i++;
continue;
}
if (inCode) {
codeLines.push(line);
i++;
continue;
}
if (line.trim() === "") {
flushParagraph();
i++;
continue;
}
// Image directive: ![IMG:path|caption]
const imgMatch = line.match(/^!\[IMG:([^|\]]+)(\|([^\]]*))?\]/);
if (imgMatch) {
flushParagraph();
elements.push(...imageParagraph(imgMatch[1].trim(), imgMatch[3] ? imgMatch[3].trim() : ""));
i++;
continue;
}
// Table block
if (line.trim().startsWith("|")) {
flushParagraph();
const block = [];
while (i < lines.length && lines[i].trim().startsWith("|")) {
block.push(lines[i]);
i++;
}
elements.push(markdownTableWrap(block));
elements.push(new Paragraph({ text: "", spacing: { after: 160 } }));
continue;
}
// Headings
let m;
if ((m = line.match(/^####\s+(.*)/))) {
flushParagraph();
elements.push(new Paragraph({ text: m[1], heading: HeadingLevel.HEADING_4, spacing: { before: 200, after: 100 } }));
i++;
continue;
}
if ((m = line.match(/^###\s+(.*)/))) {
flushParagraph();
elements.push(new Paragraph({ text: m[1], heading: HeadingLevel.HEADING_3, spacing: { before: 240, after: 120 } }));
i++;
continue;
}
if ((m = line.match(/^##\s+(.*)/))) {
flushParagraph();
elements.push(new Paragraph({ text: m[1], heading: HeadingLevel.HEADING_2, spacing: { before: 300, after: 140 } }));
i++;
continue;
}
if ((m = line.match(/^#\s+(.*)/))) {
flushParagraph();
elements.push(new Paragraph({ text: m[1], heading: HeadingLevel.HEADING_1, spacing: { before: 360, after: 160 }, pageBreakBefore: true }));
i++;
continue;
}
// Bullet list
if (line.trim().startsWith("- ") || line.trim().startsWith("* ")) {
flushParagraph();
const content = line.trim().slice(2);
elements.push(
new Paragraph({
bullet: { level: 0 },
children: parseInlineBold(content),
spacing: { after: 60 },
})
);
i++;
continue;
}
// Numbered list
if (/^\d+\.\s/.test(line.trim())) {
flushParagraph();
const content = line.trim().replace(/^\d+\.\s/, "");
elements.push(
new Paragraph({
numbering: { reference: "numbered-list", level: 0 },
children: parseInlineBold(content),
spacing: { after: 60 },
})
);
i++;
continue;
}
// Horizontal rule
if (line.trim() === "---") {
flushParagraph();
elements.push(
new Paragraph({
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "D1D5DB" } },
spacing: { before: 200, after: 200 },
})
);
i++;
continue;
}
// Regular paragraph line: accumulate into buffer (joined with the next
// lines up to the next blank line / structural line) so that inline
// markup like **gras** spanning a soft line-wrap in the source .md is
// parsed as one continuous string instead of being cut mid-token.
paraBuffer.push(line.trim());
i++;
}
flushParagraph();
return elements;
}
function markdownTableWrap(block) {
return parseTable(block);
}
// -------------------- Assemblage du document --------------------------------
const ORDER = [
"03_cahier_des_charges.md",
"04_annexe_A1_qualite_code.md",
"05_annexe_A2_estimation_charge.md",
"06_partie3_devops.md",
"07_partie4_plan_de_tests.md",
"08_partie5_retrodocumentation.md",
"09_note_rgpd.md",
"01_journal_de_bord.md",
"02_planning_previsionnel.md",
"10_captures_ecran.md",
"11_table_correspondance.md",
];
function titlePage() {
return [
new Paragraph({ text: "", spacing: { before: 1200 } }),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "GESTHUB", bold: true, size: 64, color: "1f2937" })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 200 },
children: [new TextRun({ text: "Dossier technique v2", bold: true, size: 36, color: "2563eb" })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 100 },
children: [new TextRun({ text: "Annexe de preuves du dossier de validation RNCP 36463", size: 24, color: "6B7280" })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 60 },
children: [new TextRun({ text: "Concepteur Développeur d'Applications Numériques (CDAN)", size: 22, italics: true, color: "6B7280" })],
}),
new Paragraph({ text: "", spacing: { before: 800 } }),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "LABAT Nino", bold: true, size: 26 })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 40 },
children: [new TextRun({ text: "Bordeaux Ynov Campus — B3 Robotique & Systèmes Embarqués", size: 20 })],
}),
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 40 },
children: [new TextRun({ text: "Août 2026", size: 20 })],
}),
new Paragraph({ text: "", spacing: { before: 1000 } }),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "Dépôt : https://git.ninolbt.com/Nono/gesthub", size: 18, color: "6B7280" })],
}),
new Paragraph({ children: [new PageBreak()] }),
];
}
function introSection() {
const text = [
"Ce document est le « Dossier technique GestHub v2 » référencé tout au long de la Partie 2 (Portefeuille de preuves) du dossier de validation RNCP. Il constitue l'annexe de preuves : cahier des charges, architecture, modèle de données, plan de tests, rétro-documentation, journal de bord et éléments de qualité de code, avec, chaque fois que possible, des résultats réels et vérifiables (sorties de tests, de flake8, de radon, captures d'écran) plutôt que des affirmations non étayées.",
"Deux niveaux de preuve sont distingués dans ce document, à l'identique du dossier RNCP narratif : les éléments marqués « pratiqué » correspondent à du code exécuté et vérifié au moment de la rédaction (août 2026) ; les éléments marqués « [⇒] Projection méthodologique » correspondent à une démarche comprise et documentée mais non mise en œuvre faute de contexte (pas de client réel, pas d'environnement de production avec nom de domaine, pas d'équipe).",
];
const els = [
new Paragraph({ text: "Avant-propos", heading: HeadingLevel.HEADING_1, spacing: { after: 160 } }),
];
text.forEach((t) => els.push(new Paragraph({ children: parseInlineBold(t), spacing: { after: 160 } })));
els.push(new Paragraph({ children: [new PageBreak()] }));
return els;
}
function sommaire() {
return [
new Paragraph({ text: "Sommaire", heading: HeadingLevel.HEADING_1, spacing: { after: 160 } }),
new Paragraph({
spacing: { after: 200 },
children: [
new TextRun({
text: "(Sommaire interactif — dans Word : clic droit sur la table ci-dessous puis « Mettre à jour les champs », ou Ctrl+A puis F9.)",
italics: true, size: 18, color: "6B7280",
}),
],
}),
new TableOfContents("Sommaire", { hyperlink: true, headingStyleRange: "1-3" }),
new Paragraph({ children: [new PageBreak()] }),
];
}
let body = [];
body.push(...titlePage());
body.push(...introSection());
body.push(...sommaire());
for (const file of ORDER) {
const md = fs.readFileSync(path.join(DOCS_DIR, file), "utf-8");
body.push(...markdownToElements(md));
}
const doc = new Document({
numbering: {
config: [
{
reference: "numbered-list",
levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.START }],
},
],
},
styles: {
default: {
document: { run: { font: "Calibri", size: 21 } },
},
paragraphStyles: [
{ id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { bold: true, size: 30, color: "1f2937" }, paragraph: { spacing: { before: 360, after: 160 } } },
{ id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { bold: true, size: 25, color: "2563eb" }, paragraph: { spacing: { before: 280, after: 140 } } },
{ id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { bold: true, size: 22, color: "16a34a" }, paragraph: { spacing: { before: 220, after: 100 } } },
{ id: "Heading4", name: "Heading 4", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { bold: true, italics: true, size: 20, color: "374151" }, paragraph: { spacing: { before: 180, after: 80 } } },
],
},
sections: [
{
properties: {
page: {
size: { width: 11906, height: 16838 }, // A4
margin: { top: 1134, bottom: 1134, left: 1134, right: 1134 },
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [new TextRun({ text: "GestHub — Dossier technique v2", size: 16, color: "9CA3AF" })],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ children: [PageNumber.CURRENT], size: 16, color: "9CA3AF" }),
new TextRun({ text: " / ", size: 16, color: "9CA3AF" }),
new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 16, color: "9CA3AF" }),
],
}),
],
}),
},
children: body,
},
],
});
Packer.toBuffer(doc).then((buffer) => {
fs.writeFileSync(path.join(DOCS_DIR, "Dossier_technique_GestHub_v2.docx"), buffer);
console.log("OK : Dossier_technique_GestHub_v2.docx généré");
});