import fs from 'node:fs/promises'; import { readFileSync } from 'node:fs'; 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) // A literal
is the only markup allowed through, so a table cell can list // several files on separate lines the way the reference package does. .replaceAll('<br>', '
') .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 isControlBlock(lines) { return tableCells(lines[0] ?? '')[0] === 'Document No'; } // The document-control block is a label/value grid, not a data table: odd cells are // labels, even cells are values, and a row with one pair spans the full width. function renderControlBlock(lines) { const rows = lines.map(tableCells); const body = rows.map((cells) => { const pairs = []; for (let i = 0; i < cells.length; i += 2) pairs.push([cells[i], cells[i + 1] ?? '']); const span = pairs.length === 1 ? ' colspan="3"' : ''; return `${pairs.map(([label, value]) => `${renderInline(label)}${renderInline(value)}`).join('')}`; }).join(''); return `${body}
`; } 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 row with only its first cell filled is a group heading (CR01, SR06, UN04, // WP 1.0 …): it spans the full width and does not size the columns. Signature // tables keep their blank cells for signing. const isGroup = (row) => className !== 'signature-table' && header.length >= 3 && Boolean(row[0]) && row.slice(1).every((cell) => !cell); // 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.filter((row) => !isGroup(row)).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)}`; }).join(''); const classes = [className, header.length >= 8 ? 'dense' : ''].filter(Boolean).join(' '); return `${cells('th', header)}${data.map((row) => isGroup(row) ? `${renderInline(row[0])}` : `${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, baseDir) { 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; } // A figure: ![caption](relative/path.png), embedded so the PDF needs no external files. const image = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/); if (image) { const data = readFileSync(path.resolve(baseDir, image[2])).toString('base64'); output.push(`
${renderInline(image[1])}
`); index += 1; continue; } if (isTableLine(line)) { const table = []; while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]); if (isControlBlock(table)) { output.push(renderControlBlock(table)); continue; } const previous = output[output.length - 1] ?? ''; // Keep a signature heading and its table together on one page. if (/^

/.test(previous)) { output.push(`
${output.pop()}${renderTable(table, 'signature-table')}
`); continue; } output.push(renderTable(table)); continue; } const heading = line.match(/^(#{1,3})\s+(.+)$/); if (heading) { const level = heading[1].length; const signature = level === 2 && /^ผู้(จัดทำเอกสาร|ตรวจสอบเอกสาร|อนุมัติ)/.test(heading[2]); const attr = signature ? ' class="signature-heading"' : ''; 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(`
      ${items.join('')}
    `); 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]) && !/^!\[/.test(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 footer = markdown.match(/^\s*$/m)?.[1] ?? ''; const body = markdown .slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0)) .replace(/^\s*$/m, ''); // The control block carries the document identity used by the header and footer. const metadata = {}; const lines = body.split('\n'); 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++]); if (!isControlBlock(table)) { index -= 1; continue; } for (const cells of table.map(tableCells)) { for (let i = 0; i < cells.length; i += 2) if (cells[i]) metadata[cells[i]] = cells[i + 1] ?? ''; } break; } return { title, footer, metadata, body }; } 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, footer, metadata, body } = extractDocument(markdown); // The reference package prints every work product portrait except the // evidence index and the summary traceability matrix. const LANDSCAPE_TITLES = new Set(['List of Evidence', 'Traceability Record Table']); const isLandscape = LANDSCAPE_TITLES.has(title); const values = { logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`, documentTitle: title, documentType: metadata['Document No'] ?? title, ciBlue: '#0797d5', ciGrey: '#8e8e8e', // ribbon grey measured from the audited reference documents standard: 'ISO/IEC 29110-4-1:2018', footerTag: footer, release: metadata['Release, Version, By:'] ?? '', projectName: metadata['Project Name'] ?? 'BRN WMS', projectCode: metadata['Project Code'] ?? '200-WMS-26-001-00', pageNumber: '', totalPages: '' }; const pdfHeader = applyTemplate(headerTemplate, values); 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: 32mm 15mm 24mm 15mm; }' : ''; const templateFont = ``; const html = `
    ${markdownToHtml(body, path.dirname(source))}
    `; 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: `${templateFont}${pdfHeader}`, footerTemplate: `${templateFont}${pdfFooter}` }); } finally { await browser.close(); } } main().catch((error) => { console.error(error); process.exit(1); });