/**
* render_legal_docx.js — rende un deliverable markdown in un file .docx
* applicando, quando disponibile, il canone tipografico SAPG (Times New Roman 12,
* interlinea 1,5, rientro prima riga 0,5 cm, corpo giustificato). Se il modulo
* SAPG non è installato si applica un fallback "plain" (Times/Arial di default
* di docx-js) senza errori.
*
* Esecuzione (docx-js è installato globalmente):
* NODE_PATH=$(npm root -g) node render_legal_docx.js \
* --input deliverable.md \
* [--title "Titolo documento"] \
* [--slug nome-file] \
* [--dir /percorso/cartella/attiva]
*
* In alternativa il markdown può arrivare da stdin:
* cat deliverable.md | NODE_PATH=$(npm root -g) node render_legal_docx.js --title "Parere"
*
* Output:
/.docx — il path assoluto viene stampato su stdout.
*
* Mappatura markdown -> blocchi docx: vedi references/mapping.md.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const docx = require('docx');
const {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
AlignmentType,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
} = docx;
// ---------------------------------------------------------------------------
// CLI parsing
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { input: null, title: null, slug: null, dir: null };
for (let i = 2; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--input' || a === '-i') args.input = argv[++i];
else if (a === '--title' || a === '-t') args.title = argv[++i];
else if (a === '--slug' || a === '-s') args.slug = argv[++i];
else if (a === '--dir' || a === '-d') args.dir = argv[++i];
}
return args;
}
function readInput(inputPath) {
if (inputPath) {
return fs.readFileSync(inputPath, 'utf8');
}
// stdin fallback
try {
return fs.readFileSync(0, 'utf8');
} catch (e) {
return '';
}
}
// ---------------------------------------------------------------------------
// Slug + active folder rules (mirror references/mapping.md)
// ---------------------------------------------------------------------------
function slugify(value) {
const base = (value || 'documento')
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '') // strip accents
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return base || 'documento';
}
// ---------------------------------------------------------------------------
// SAPG canon — try-require with graceful fallback
// ---------------------------------------------------------------------------
function loadSapg() {
const candidate = path.join(
os.homedir(),
'.claude/skills/docx-sapg/assets/sapg_styles.js'
);
try {
if (fs.existsSync(candidate)) {
// eslint-disable-next-line global-require, import/no-dynamic-require
return require(candidate);
}
} catch (e) {
// fall through to null — plain defaults
}
return null;
}
// ---------------------------------------------------------------------------
// Markdown parsing — line-based block tokenizer
// ---------------------------------------------------------------------------
// Inline emphasis: **bold**, __bold__, *italic*, _italic_, `code`.
// Returns an array of docx TextRun for a single line of text.
function inlineRuns(text, runOpts) {
const opts = runOpts || {};
const runs = [];
const re = /(\*\*|__)(.+?)\1|(\*|_)(.+?)\3|`([^`]+)`/g;
let last = 0;
let m;
const push = (chunk, extra) => {
if (chunk === '') return;
runs.push(new TextRun(Object.assign({ text: chunk }, opts, extra || {})));
};
while ((m = re.exec(text)) !== null) {
if (m.index > last) push(text.slice(last, m.index));
if (m[2] !== undefined) push(m[2], { bold: true });
else if (m[4] !== undefined) push(m[4], { italics: true });
else if (m[5] !== undefined) push(m[5], { font: 'Courier New' });
last = re.lastIndex;
}
if (last < text.length) push(text.slice(last));
if (runs.length === 0) push(text);
return runs;
}
function isTableSeparator(line) {
// | --- | :---: | ---: |
return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
}
function splitTableRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
return s.split('|').map((c) => c.trim());
}
/**
* Tokenise markdown into a flat list of block descriptors:
* { type: 'heading', level, text }
* { type: 'paragraph', text }
* { type: 'list', ordered, items: [text...] }
* { type: 'quote', text }
* { type: 'table', rows: [[cell...]...] }
* { type: 'spacer' }
*/
function tokenize(md) {
const lines = md.replace(/\r\n/g, '\n').split('\n');
const blocks = [];
let i = 0;
const flushParagraph = (buf) => {
if (buf.length) blocks.push({ type: 'paragraph', text: buf.join(' ').trim() });
};
let paraBuf = [];
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// Blank line -> paragraph boundary
if (trimmed === '') {
flushParagraph(paraBuf);
paraBuf = [];
i += 1;
continue;
}
// Horizontal rule -> spacer
if (/^(\*\s*){3,}$|^(-\s*){3,}$|^(_\s*){3,}$/.test(trimmed)) {
flushParagraph(paraBuf);
paraBuf = [];
blocks.push({ type: 'spacer' });
i += 1;
continue;
}
// ATX heading
const h = /^(#{1,6})\s+(.*)$/.exec(trimmed);
if (h) {
flushParagraph(paraBuf);
paraBuf = [];
blocks.push({
type: 'heading',
level: Math.min(h[1].length, 3),
text: h[2].trim(),
});
i += 1;
continue;
}
// Blockquote (consume consecutive > lines)
if (/^>\s?/.test(trimmed)) {
flushParagraph(paraBuf);
paraBuf = [];
const qbuf = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
qbuf.push(lines[i].replace(/^\s*>\s?/, '').trim());
i += 1;
}
blocks.push({ type: 'quote', text: qbuf.join(' ').trim() });
continue;
}
// Table (header row + separator row)
if (
trimmed.includes('|') &&
i + 1 < lines.length &&
isTableSeparator(lines[i + 1])
) {
flushParagraph(paraBuf);
paraBuf = [];
const rows = [splitTableRow(lines[i])];
i += 2; // skip header + separator
while (i < lines.length && lines[i].trim().includes('|') && lines[i].trim() !== '') {
rows.push(splitTableRow(lines[i]));
i += 1;
}
blocks.push({ type: 'table', rows });
continue;
}
// List (unordered or ordered) — consume consecutive items
const ulItem = /^\s*[-*+]\s+(.*)$/.exec(line);
const olItem = /^\s*\d+[.)]\s+(.*)$/.exec(line);
if (ulItem || olItem) {
flushParagraph(paraBuf);
paraBuf = [];
const ordered = !!olItem;
const items = [];
while (i < lines.length) {
const u = /^\s*[-*+]\s+(.*)$/.exec(lines[i]);
const o = /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]);
if (ordered && o) items.push(o[1].trim());
else if (!ordered && u) items.push(u[1].trim());
else break;
i += 1;
}
blocks.push({ type: 'list', ordered, items });
continue;
}
// Otherwise: accumulate into the current paragraph
paraBuf.push(trimmed);
i += 1;
}
flushParagraph(paraBuf);
return blocks;
}
// ---------------------------------------------------------------------------
// Renderers — SAPG variant and plain variant share the same block model
// ---------------------------------------------------------------------------
const PLAIN_FONT = 'Times New Roman';
const PLAIN_SIZE = 24; // 12pt half-points
const PLAIN_LINE = 360; // 1.5 spacing
const PLAIN_FIRST_LINE = 283; // 0.5 cm twips
function plainHeadingLevel(level) {
return { 1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3 }[
level
];
}
function buildPlainParagraphs(blocks) {
const children = [];
for (const b of blocks) {
if (b.type === 'heading') {
children.push(
new Paragraph({
heading: plainHeadingLevel(b.level),
spacing: { before: 200, after: 120, line: PLAIN_LINE, lineRule: 'auto' },
children: inlineRuns(b.text, { bold: true, font: PLAIN_FONT }),
})
);
} else if (b.type === 'paragraph') {
children.push(
new Paragraph({
alignment: AlignmentType.JUSTIFIED,
indent: { firstLine: PLAIN_FIRST_LINE },
spacing: { line: PLAIN_LINE, lineRule: 'auto' },
children: inlineRuns(b.text, { font: PLAIN_FONT, size: PLAIN_SIZE }),
})
);
} else if (b.type === 'list') {
b.items.forEach((it) => {
children.push(
new Paragraph({
bullet: b.ordered ? undefined : { level: 0 },
numbering: b.ordered ? { reference: 'plain-numbers', level: 0 } : undefined,
spacing: { line: PLAIN_LINE, lineRule: 'auto' },
children: inlineRuns(it, { font: PLAIN_FONT, size: PLAIN_SIZE }),
})
);
});
} else if (b.type === 'quote') {
children.push(
new Paragraph({
indent: { left: 567, right: 567 },
spacing: { before: 120, after: 120, line: PLAIN_LINE, lineRule: 'auto' },
children: inlineRuns(b.text, { italics: true, font: PLAIN_FONT, size: PLAIN_SIZE }),
})
);
} else if (b.type === 'table') {
children.push(buildTable(b.rows, { font: PLAIN_FONT, size: PLAIN_SIZE }));
children.push(new Paragraph({ children: [] }));
} else if (b.type === 'spacer') {
children.push(new Paragraph({ children: [] }));
}
}
return children;
}
function buildSapgParagraphs(blocks, sapg) {
const { FONT, SIZE_BODY } = sapg.constants;
const children = [];
for (const b of blocks) {
if (b.type === 'heading') {
const styleId = { 1: 'SAPGHeading1', 2: 'SAPGHeading2', 3: 'SAPGHeading3' }[b.level];
children.push(
new Paragraph({
style: styleId,
children: inlineRuns(b.text, { font: FONT, bold: true, color: '000000' }),
})
);
} else if (b.type === 'paragraph') {
children.push(
new Paragraph({
style: 'SAPGBody',
children: inlineRuns(b.text, { font: FONT, size: SIZE_BODY, color: '000000' }),
})
);
} else if (b.type === 'list') {
b.items.forEach((it) => {
children.push(
new Paragraph({
style: 'SAPGBody',
indent: { left: 720, hanging: 360, firstLine: undefined },
numbering: {
reference: b.ordered ? 'sapg-numbers' : 'sapg-bullets',
level: 0,
},
children: inlineRuns(it, { font: FONT, size: SIZE_BODY, color: '000000' }),
})
);
});
} else if (b.type === 'quote') {
children.push(
new Paragraph({
style: 'SAPGQuote',
children: inlineRuns(b.text, { font: FONT, size: SIZE_BODY, color: '000000', italics: true }),
})
);
} else if (b.type === 'table') {
children.push(buildTable(b.rows, { font: FONT, size: SIZE_BODY }));
children.push(new Paragraph({ style: 'SAPGBody', children: [] }));
} else if (b.type === 'spacer') {
children.push(new Paragraph({ children: [] }));
}
}
return children;
}
function buildTable(rows, runOpts) {
const border = { style: BorderStyle.SINGLE, size: 4, color: '999999' };
const borders = {
top: border,
bottom: border,
left: border,
right: border,
insideHorizontal: border,
insideVertical: border,
};
const tableRows = rows.map((cells, rowIdx) =>
new TableRow({
children: cells.map(
(cell) =>
new TableCell({
children: [
new Paragraph({
children: inlineRuns(cell, Object.assign({}, runOpts, rowIdx === 0 ? { bold: true } : {})),
}),
],
})
),
})
);
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders,
rows: tableRows,
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = parseArgs(process.argv);
const md = readInput(args.input);
if (!md || md.trim() === '') {
console.error('FAIL: nessun contenuto markdown fornito (usa --input o stdin).');
process.exit(1);
}
const blocks = tokenize(md);
const sapg = loadSapg();
// Derive title: explicit --title, else first H1, else "Documento".
let title = args.title;
if (!title) {
const firstHeading = blocks.find((b) => b.type === 'heading');
title = firstHeading ? firstHeading.text : 'Documento';
}
const slug = slugify(args.slug || title);
const outDir = args.dir ? path.resolve(args.dir) : process.cwd();
const outputPath = path.join(outDir, `${slug}.docx`);
let doc;
if (sapg) {
doc = new Document({
creator: 'mcp-legal-it',
title,
description: 'Deliverable generato da esporta-documento (canone SAPG)',
styles: {
default: sapg.sapgDefaults(),
paragraphStyles: sapg.sapgParagraphStyles(),
},
numbering: sapg.sapgNumbering(),
sections: [
{
properties: sapg.sapgPageProperties(),
children: buildSapgParagraphs(blocks, sapg),
},
],
});
} else {
doc = new Document({
creator: 'mcp-legal-it',
title,
description: 'Deliverable generato da esporta-documento (stile predefinito)',
numbering: {
config: [
{
reference: 'plain-numbers',
levels: [
{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.LEFT },
],
},
],
},
sections: [
{
children: buildPlainParagraphs(blocks),
},
],
});
}
Packer.toBuffer(doc)
.then((buffer) => {
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(outputPath, buffer);
// Echo the absolute path so the skill can report it.
console.log(outputPath);
})
.catch((err) => {
console.error('FAIL:', err && err.message ? err.message : err);
process.exit(1);
});
}
main();