261 lines
11 KiB
JavaScript
261 lines
11 KiB
JavaScript
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 <br> 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>', '<br>')
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
.replace(/_{3,}/g, '<span class="approval-block__line"></span>')
|
|
.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 `<tr>${pairs.map(([label, value]) => `<th>${renderInline(label)}</th><td${span}>${renderInline(value)}</td>`).join('')}</tr>`;
|
|
}).join('');
|
|
return `<table class="document-control"><tbody>${body}</tbody></table>`;
|
|
}
|
|
|
|
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('');
|
|
const classes = [className, header.length >= 8 ? 'dense' : ''].filter(Boolean).join(' ');
|
|
return `<table${classes ? ` class="${classes}"` : ''}><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, 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: , 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(`<figure class="figure"><img src="data:image/png;base64,${data}" alt=""><figcaption>${renderInline(image[1])}</figcaption></figure>`);
|
|
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 (/^<h2 class="signature-heading">/.test(previous)) {
|
|
output.push(`<div class="signature-block">${output.pop()}${renderTable(table, 'signature-table')}</div>`);
|
|
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(`<h${level}${attr}>${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;
|
|
}
|
|
|
|
if (/^\d+\.\s+/.test(line)) {
|
|
const items = [];
|
|
while (index < lines.length && /^\d+\.\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^\d+\.\s+/, ''))}</li>`);
|
|
output.push(`<ol>${items.join('')}</ol>`);
|
|
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 = `<p${isApproval ? ' class="approval-fields"' : ''}>${renderParagraph(paragraph)}</p>`;
|
|
// Keep a signatory's heading and fields on one page.
|
|
if (isApproval && /^<h3>/.test(output[output.length - 1] ?? '')) {
|
|
output.push(`<div class="approval-block__person">${output.pop()}${html}</div>`);
|
|
} 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*footer:\s*(.+?)\s*-->\s*$/m)?.[1] ?? '';
|
|
const body = markdown
|
|
.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0))
|
|
.replace(/^<!--\s*footer:.*?-->\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: '<span class="pageNumber"></span>',
|
|
totalPages: '<span class="totalPages"></span>'
|
|
};
|
|
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 = `<style>${fontFace}</style>`;
|
|
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${markdownToHtml(body, path.dirname(source))}</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: `${templateFont}${pdfHeader}`,
|
|
footerTemplate: `${templateFont}${pdfFooter}`
|
|
});
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => { console.error(error); process.exit(1); });
|