import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '../..');
const assets = path.join(here, 'assets');
const escapeHtml = (value) => value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>');
const renderInline = (value) => escapeHtml(value)
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/_{3,}/g, '')
.replace(/\\([\\*_`~])/g, '$1');
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
function isTableLine(line) {
return /^\|.*\|\s*$/.test(line.trim());
}
function tableCells(line) {
return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
}
function isSeparator(cells) {
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
}
const TIGHT_COLUMN_MAX = 12;
function columnAlignment(separatorCells) {
return separatorCells.map((cell) => {
const left = cell.startsWith(':');
const right = cell.endsWith(':');
if (left && right) return 'a-center';
if (right) return 'a-right';
return '';
});
}
function renderTable(lines, className = '') {
const rows = lines.map(tableCells);
const header = rows[0];
const hasSeparator = isSeparator(rows[1] ?? []);
const align = hasSeparator ? columnAlignment(rows[1]) : header.map(() => '');
const data = hasSeparator ? rows.slice(2) : rows.slice(1);
// A column whose longest cell is short (IDs, dates, counts, statuses) is
// marked "tight" so auto table layout shrinks it to its content instead of
// handing every column an equal share of the page width.
const widest = header.map((_, column) => Math.max(
(header[column] ?? '').length,
...data.map((row) => (row[column] ?? '').replace(/[`*]/g, '').length)
));
const classFor = (column) => [
align[column] ?? '',
widest[column] <= TIGHT_COLUMN_MAX ? 'tight' : ''
].filter(Boolean).join(' ');
const cells = (tag, row) => row.map((cell, column) => {
const attr = classFor(column);
return `<${tag}${attr ? ` class="${attr}"` : ''}>${renderInline(cell)}${tag}>`;
}).join('');
return `
${cells('th', header)}
${data.map((row) => `${cells('td', row)}
`).join('')}
`;
}
function renderParagraph(lines) {
return lines.map((line, index) => {
const trimmed = line.trimEnd();
const hasHardBreak = /\s{2,}$/.test(line);
const isApprovalField = /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(trimmed);
const separator = hasHardBreak || isApprovalField ? '
' : (index < lines.length - 1 ? ' ' : '');
return `${renderInline(trimmed)}${separator}`;
}).join('');
}
function markdownToHtml(markdown) {
const lines = markdown.replaceAll('\r\n', '\n').split('\n');
const output = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) { index += 1; continue; }
if (isTableLine(line)) {
const table = [];
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
output.push(renderTable(table));
continue;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
output.push(`${renderInline(heading[2])}`);
index += 1;
continue;
}
if (/^[-*]\s+/.test(line)) {
const items = [];
while (index < lines.length && /^[-*]\s+/.test(lines[index])) items.push(`${renderInline(lines[index++].replace(/^[-*]\s+/, ''))}`);
output.push(``);
continue;
}
if (/^\d+\.\s+/.test(line)) {
const items = [];
while (index < lines.length && /^\d+\.\s+/.test(lines[index])) items.push(`${renderInline(lines[index++].replace(/^\d+\.\s+/, ''))}`);
output.push(`${items.join('')}
`);
continue;
}
const paragraph = [];
while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index]) && !/^\d+\.\s+/.test(lines[index])) paragraph.push(lines[index++]);
const isApproval = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd()));
const html = `${renderParagraph(paragraph)}
`;
// Keep a signatory's heading and fields on one page.
if (isApproval && /^/.test(output[output.length - 1] ?? '')) {
output.push(`
${output.pop()}${html}
`);
} else {
output.push(html);
}
}
return output.join('\n');
}
function extractDocument(markdown) {
const titleMatch = markdown.match(/^#\s+(.+)\n+/m);
const title = titleMatch?.[1] ?? 'BRN WMS';
const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
const lines = afterTitle.split('\n');
const metadata = {};
let bodyStart = 0;
for (let index = 0; index < lines.length; index += 1) {
if (!isTableLine(lines[index])) continue;
const table = [];
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
const candidate = {};
const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
for (const row of table.slice(dataStart).map(tableCells)) {
if (row.length >= 2) candidate[row[0]] = row[1];
}
if (candidate.Document && candidate.Project) {
Object.assign(metadata, candidate);
bodyStart = index;
break;
}
index -= 1;
}
return { title, metadata, body: lines.slice(bodyStart).join('\n') };
}
async function main() {
const [source, destination] = process.argv.slice(2);
if (!source || !destination) throw new Error('Usage: render-sdlc.mjs SOURCE.md DESTINATION.pdf');
const [markdown, css, headerTemplate, footerTemplate, logo, thaiFont] = await Promise.all([
fs.readFile(source, 'utf8'),
fs.readFile(path.join(assets, 'sdlc-delivery.css'), 'utf8'),
fs.readFile(path.join(assets, 'header.html'), 'utf8'),
fs.readFile(path.join(assets, 'footer.html'), 'utf8'),
fs.readFile(path.join(root, 'app/assets/images/logo.svg')),
fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
]);
const { title, metadata, body } = extractDocument(markdown);
const hasWideTable = markdown.split('\n').some((line) => isTableLine(line) && tableCells(line).length >= 8);
const isLandscape = metadata.Document === 'Work Schedule' || title === 'Work Schedule' || hasWideTable;
const values = {
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
documentTitle: title,
documentType: metadata.Document ?? title,
release: metadata.Release ?? '',
projectName: metadata.Project ?? 'BRN WMS',
projectCode: metadata['Project code'] ?? '200-WMS-26-001-00',
projectPeriod: metadata['Project period'] ?? '05/01/26–24/08/26',
pageNumber: '',
totalPages: ''
};
const control = await fs.readFile(path.join(assets, 'document-control.html'), 'utf8');
const pdfFooter = applyTemplate(footerTemplate, values);
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${thaiFont.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
const pageLayout = isLandscape
? '@page { size: A4 landscape; margin: 18mm 16mm 28mm; }'
: '';
const html = `${applyTemplate(headerTemplate, values)}${applyTemplate(control, values)}${markdownToHtml(body)}`;
await fs.mkdir(path.dirname(destination), { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
await page.pdf({
path: destination,
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: true,
headerTemplate: '',
footerTemplate: pdfFooter
});
} finally {
await browser.close();
}
}
main().catch((error) => { console.error(error); process.exit(1); });