176 lines
6.8 KiB
JavaScript
176 lines
6.8 KiB
JavaScript
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, '<strong>$1</strong>')
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
|
||
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)}</${tag}>`).join('');
|
||
return `<table${className ? ` class="${className}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
|
||
}
|
||
|
||
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 ? '<br>' : (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(`<h${level}>${renderInline(heading[2])}</h${level}>`);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (/^[-*]\s+/.test(line)) {
|
||
const items = [];
|
||
while (index < lines.length && /^[-*]\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^[-*]\s+/, ''))}</li>`);
|
||
output.push(`<ul>${items.join('')}</ul>`);
|
||
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(`<p${approvalClass}>${renderParagraph(paragraph)}</p>`);
|
||
}
|
||
|
||
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: '<span class="pageNumber"></span>',
|
||
totalPages: '<span class="totalPages"></span>'
|
||
};
|
||
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 = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${applyTemplate(headerTemplate, values)}${applyTemplate(control, values)}${markdownToHtml(body)}</main></body></html>`;
|
||
|
||
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: '<div></div>',
|
||
footerTemplate: pdfFooter
|
||
});
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
}
|
||
|
||
main().catch((error) => { console.error(error); process.exit(1); });
|