Trace every CR to a test case or delivered document, drop the draft FR/NFR remark column, record lessons learned, replace the three change reports with a change-control register, add high-level diagrams and wireframes to the design, plan verification rounds with dates and hours, and base the validation result on the Customer Requirements.
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); });
|