Files
wms-app/scripts/sdlc-delivery/render-sdlc.mjs
T

472 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { headerValues } from './letterhead.mjs';
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('<', '&lt;')
.replaceAll('>', '&gt;');
// Requirement, unit, test and issue IDs (CR01:002, TC-UN08.002, ISS-001) and
// short Thai dates (16 ก.ค. 69, 10–14 ส.ค. 69, 10 สิงหาคม 2569) never break across lines;
// otherwise a cramped column splits them a few letters per line.
const THAI_MONTHS = 'ม\\.ค\\.|ก\\.พ\\.|มี\\.ค\\.|เม\\.ย\\.|พ\\.ค\\.|มิ\\.ย\\.|ก\\.ค\\.|ส\\.ค\\.|ก\\.ย\\.|ต\\.ค\\.|พ\\.ย\\.|ธ\\.ค\\.'
+ '|มกราคม|กุมภาพันธ์|มีนาคม|เมษายน|พฤษภาคม|มิถุนายน|กรกฎาคม|สิงหาคม|กันยายน|ตุลาคม|พฤศจิกายน|ธันวาคม';
const UNBREAKABLE = new RegExp(`\\b(?:TC-)?[A-Z]{2,4}-?\\d{2,3}(?:[:.]\\d{3})?\\b|\\d{1,2}(?:–\\d{1,2})? (?:${THAI_MONTHS}) \\d{2,4}`, 'g');
const renderInline = (value) => escapeHtml(value)
.replace(UNBREAKABLE, '<span class="nobr">$&</span>')
// 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('&lt;br&gt;', '<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 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)}</${tag}>`;
}).join('');
const classes = [className, header.length >= 7 ? 'dense' : ''].filter(Boolean).join(' ');
return `<table${classes ? ` class="${classes}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => isGroup(row)
? `<tr class="group"><td colspan="${header.length}">${renderInline(row[0])}</td></tr>`
: `<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: ![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(`<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);
// Work products print portrait unless a table needs the width: the evidence
// index and the summary traceability matrix always do, and any other document
// turns landscape when one of its tables needs more than the portrait width
// (see tableWidthNeededMm below).
const LANDSCAPE_TITLES = new Set(['List of Evidence', 'Traceability Record Table']);
const values = {
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
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 headerFor = (landscape) => applyTemplate(headerTemplate, { ...values, ...headerValues(title, landscape) });
const footerFor = (landscape) => applyTemplate(footerTemplate, {
...values,
pageWidth: landscape ? '297mm' : '210mm',
textLeft: landscape ? '15mm' : '30mm',
textRight: landscape ? '15mm' : '20mm',
textWidth: landscape ? '267mm' : '160mm'
});
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${thaiFont.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
const templateFont = `<style>${fontFace}</style>`;
const content = markdownToHtml(body, path.dirname(source));
const htmlFor = (landscape) => {
const pageLayout = landscape ? '@page { size: A4 landscape; margin: 32mm 15mm 24mm 15mm; }' : '';
return `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${content}</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(htmlFor(false), { waitUntil: 'networkidle' });
const neededMm = await tableWidthNeededMm(page);
if (process.env.SDLC_ORIENT_DEBUG) console.log(`${Math.round(neededMm)}mm ${title}`);
const landscape = LANDSCAPE_TITLES.has(title) || neededMm > PORTRAIT_TEXT_MM;
if (landscape) await page.setContent(htmlFor(true), { waitUntil: 'networkidle' });
const textMm = landscape ? 267 : PORTRAIT_TEXT_MM;
const clipped = await applyProportionalWidths(page, textMm);
// The Word copy is rebuilt from the PDF by pdf2docx, which loses these widths;
// the converter reads them back from this file (see convert-to-docx.py).
if (process.env.SDLC_WIDTHS_FILE) {
const sized = await page.evaluate(() => [...document.querySelectorAll('main table')]
.filter((table) => table.querySelector('colgroup'))
.map((table) => ({
header: [...table.tHead.rows[0].cells].map((cell) => cell.textContent.trim()),
widths: [...table.querySelectorAll('colgroup col')].map((col) => parseFloat(col.style.width))
})));
await fs.mkdir(path.dirname(process.env.SDLC_WIDTHS_FILE), { recursive: true });
await fs.writeFile(process.env.SDLC_WIDTHS_FILE, `${JSON.stringify(sized)}\n`);
}
for (const what of clipped) console.error(`WARNING ${path.basename(source)}: ${what} does not fit its column`);
// Unbreakable IDs can make a table wider than the page; say so rather than print it clipped.
const overflowMm = await page.evaluate((mm) => {
const main = document.querySelector('main.delivery-document');
main.style.width = `${mm}mm`; // the screen viewport is wider than the printed text block
const over = Math.max(0, ...[...main.querySelectorAll('table')].map((table) => (table.scrollWidth - main.clientWidth) / (96 / 25.4)));
main.style.width = '';
return over;
}, textMm);
if (overflowMm > 1) console.error(`WARNING ${path.basename(source)}: a table is ${overflowMm.toFixed(0)}mm wider than the page`);
await page.pdf({
path: destination,
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: true,
headerTemplate: `${templateFont}${headerFor(landscape)}`,
footerTemplate: `${templateFont}${footerFor(landscape)}`
});
} finally {
await browser.close();
}
}
/**
* True when a table needs more than the 160mm portrait text block. Each column
* is given its one-line width, capped at COLUMN_CAP_MM for prose columns that
* are meant to wrap; when those widths add up to more than the page, portrait
* would stack short values (IDs, dates, statuses) one word per line.
*/
const PORTRAIT_TEXT_MM = 160;
const COLUMN_CAP_MM = Number(process.env.SDLC_COLUMN_CAP_MM ?? 30);
async function tableWidthNeededMm(page) {
return page.evaluate((capMm) => {
const mm = 96 / 25.4;
const main = document.querySelector('main.delivery-document');
const probe = document.createElement('style');
probe.textContent = '.width-probe { position: absolute; left: -10000mm; width: max-content; } '
+ '.width-probe table { width: auto !important; } .width-probe th, .width-probe td { white-space: nowrap !important; }';
document.head.append(probe);
let widest = 0;
for (const table of main.querySelectorAll('table:not(.document-control)')) {
const holder = document.createElement('div');
holder.className = 'delivery-document width-probe';
holder.append(table.cloneNode(true));
document.body.append(holder);
const columns = [];
for (const row of holder.querySelector('table').rows) {
let index = 0;
for (const cell of row.cells) {
if (cell.colSpan === 1) columns[index] = Math.max(columns[index] ?? 0, cell.getBoundingClientRect().width / mm);
index += cell.colSpan;
}
}
holder.remove();
widest = Math.max(widest, columns.reduce((sum, w) => sum + Math.min(w ?? 0, capMm), 0));
}
probe.remove();
return widest;
}, COLUMN_CAP_MM);
}
/**
* Column widths proportional to content. Each column's share of the text width
* follows the average one-line width of its cells, but it never drops below the
* narrowest width that keeps its words and IDs whole, and never grows beyond its
* longest line; the widths are then fixed so the browser does not redistribute
* them. Returns the IDs or dates that still stick out of their cell.
*/
async function applyProportionalWidths(page, textMm) {
return page.evaluate(({ availMm, SHORT_COLUMN_MM, SHORT_CELL_CHARS }) => {
const mm = 96 / 25.4;
const main = document.querySelector('main.delivery-document');
const probe = document.createElement('style');
probe.textContent = '.width-probe { position: absolute; left: -10000mm; width: max-content; } '
+ '.width-probe table { width: auto !important; table-layout: auto !important; } '
+ '.width-probe.one-line th, .width-probe.one-line td { white-space: nowrap !important; } '
+ '.width-probe.narrowest table { width: 1px !important; } '
+ '.width-probe.narrowest th, .width-probe.narrowest td { overflow-wrap: normal !important; }';
document.head.append(probe);
// Per column: the width of every single-column cell's own content (plus its
// padding) in one probe layout. A cell's box is as wide as its whole column,
// so the content is measured with a range rather than the cell's rectangle.
const contentWidth = (cell) => {
const range = document.createRange();
range.selectNodeContents(cell);
const rects = [...range.getClientRects()];
const text = rects.length ? Math.max(...rects.map((r) => r.right)) - Math.min(...rects.map((r) => r.left)) : 0;
const style = getComputedStyle(cell);
// 1.2mm slack for the cell borders and glyphs that overhang their advance width.
return text + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + 1.2 * mm;
};
const measure = (table, mode) => {
const holder = document.createElement('div');
holder.className = `delivery-document width-probe ${mode}`;
holder.append(table.cloneNode(true));
document.body.append(holder);
const columns = [];
for (const row of holder.querySelector('table').rows) {
let index = 0;
for (const cell of row.cells) {
if (cell.colSpan === 1) (columns[index] ??= []).push({ width: contentWidth(cell) / mm, head: cell.tagName === 'TH', short: cell.textContent.trim().length <= SHORT_CELL_CHARS });
index += cell.colSpan;
}
}
holder.remove();
return columns;
};
const tables = [...main.querySelectorAll('table:not(.document-control):not(.signature-table)')];
for (const table of tables) {
const line = measure(table, 'one-line');
const narrow = measure(table, 'narrowest');
const n = line.length;
if (n < 2 || [...line].some((c) => !c)) continue;
const longest = line.map((c) => Math.max(...c.map((x) => x.width)));
let floor = narrow.map((c, i) => Math.min(Math.max(...c.map((x) => x.width)), longest[i]));
// Short columns (IDs, dates, statuses) stay on one line when the page allows;
// the prose columns then share what is left in proportion to their content.
// So do short entries in any column - a status word, a list of initials.
const shortest = line.map((c) => Math.max(0, ...c.filter((x) => x.short && !x.head).map((x) => x.width)));
const oneLine = floor.map((f, i) => (longest[i] <= SHORT_COLUMN_MM ? longest[i] : Math.max(f, shortest[i])));
if (oneLine.reduce((a, b) => a + b, 0) < availMm) floor = oneLine;
const weight = line.map((c) => {
const body = c.filter((x) => !x.head);
const cells = body.length ? body : c;
return Math.max(1, cells.reduce((sum, x) => sum + x.width, 0) / cells.length);
});
let widths;
const floorSum = floor.reduce((a, b) => a + b, 0);
if (floorSum >= availMm) {
widths = floor.map((f) => (f * availMm) / floorSum);
} else {
// Water-filling: share by weight, pin columns that hit their floor or longest line, repeat.
const pinned = new Array(n).fill(null);
for (let changed = true; changed;) {
changed = false;
const free = availMm - pinned.reduce((a, w) => a + (w ?? 0), 0);
const open = [...Array(n).keys()].filter((i) => pinned[i] === null);
if (!open.length) break;
const total = open.reduce((a, i) => a + weight[i], 0);
for (const i of open) {
if ((free * weight[i]) / total < floor[i]) { pinned[i] = floor[i]; changed = true; }
}
if (changed) continue;
for (const i of open) {
if ((free * weight[i]) / total > longest[i]) { pinned[i] = longest[i]; changed = true; }
}
if (!changed) for (const i of open) pinned[i] = (free * weight[i]) / total;
}
widths = pinned;
// Every column fits on one line with room to spare: spread the rest by weight.
const used = widths.reduce((a, b) => a + b, 0);
if (used < availMm) {
const total = weight.reduce((a, b) => a + b, 0);
widths = widths.map((w, i) => w + ((availMm - used) * weight[i]) / total);
}
}
// Topic columns (CR Topic, SRS Topic, Unit Topic) share their combined width equally.
const heads = [...(table.tHead?.rows[0]?.cells ?? [])].map((cell) => cell.textContent.trim());
const topics = heads.map((h, i) => (/Topic$/i.test(h) ? i : -1)).filter((i) => i >= 0);
if (topics.length > 1 && heads.length === n) {
const even = topics.reduce((sum, i) => sum + widths[i], 0) / topics.length;
if (topics.every((i) => even >= floor[i])) for (const i of topics) widths[i] = even;
}
const colgroup = document.createElement('colgroup');
for (const w of widths) {
const col = document.createElement('col');
col.style.width = `${((w / availMm) * 100).toFixed(2)}%`;
colgroup.append(col);
}
table.prepend(colgroup);
table.style.tableLayout = 'fixed';
}
probe.remove();
// An ID or date wider than its column would print over the next cell.
main.style.width = `${availMm}mm`;
const clipped = [...main.querySelectorAll('td .nobr, th .nobr')]
.filter((span) => span.getBoundingClientRect().right > span.closest('td, th').getBoundingClientRect().right + 0.5)
.map((span) => span.textContent);
main.style.width = '';
return [...new Set(clipped)];
}, { availMm: textMm, SHORT_COLUMN_MM: 35, SHORT_CELL_CHARS: 16 });
}
main().catch((error) => { console.error(error); process.exit(1); });