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'); 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)); } function renderTable(lines, className = '') { const rows = lines.map(tableCells); const header = rows[0]; const data = isSeparator(rows[1] ?? []) ? rows.slice(2) : rows.slice(1); const cells = (tag, row) => row.map((cell) => `<${tag}>${renderInline(cell)}`).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; } const paragraph = []; while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index])) paragraph.push(lines[index++]); const approvalClass = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd())) ? ' class="approval-fields"' : ''; output.push(`${renderParagraph(paragraph)}

    `); } 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); });