docs(sdlc): content-sized columns in Word, trace record with test cases, PSRs presented at minuted meetings

This commit is contained in:
Thanakorn
2026-10-02 16:35:31 +07:00
parent 64b1be55d8
commit 203b541c59
139 changed files with 322 additions and 170 deletions
+34 -13
View File
@@ -3,6 +3,7 @@ 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, '../..');
@@ -101,7 +102,7 @@ function renderTable(lines, className = '') {
const attr = classFor(column);
return `<${tag}${attr ? ` class="${attr}"` : ''}>${renderInline(cell)}</${tag}>`;
}).join('');
const classes = [className, header.length >= 8 ? 'dense' : ''].filter(Boolean).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>`;
@@ -233,9 +234,6 @@ async function main() {
const LANDSCAPE_TITLES = new Set(['List of Evidence', 'Traceability Record Table']);
const values = {
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
documentTitle: title,
// The banner holds about 22 characters at 13.5pt; longer titles shrink to fit.
titleSize: `${Math.min(13.5, (13.5 * 22) / title.length).toFixed(1)}pt`,
documentType: metadata['Document No'] ?? title,
ciBlue: '#0797d5',
ciGrey: '#8e8e8e', // ribbon grey measured from the audited reference documents
@@ -247,13 +245,7 @@ async function main() {
pageNumber: '<span class="pageNumber"></span>',
totalPages: '<span class="totalPages"></span>'
};
// Landscape pages are 87mm wider: the ribbon stretches and the banner keeps its distance from the right edge.
const headerFor = (landscape) => applyTemplate(headerTemplate, {
...values,
pageWidth: landscape ? '297mm' : '210mm',
ribbonWidth: `${64.7 + (landscape ? 87 : 0)}mm`,
bannerLeft: `${127.2 + (landscape ? 87 : 0)}mm`
});
const headerFor = (landscape) => applyTemplate(headerTemplate, { ...values, ...headerValues(title, landscape) });
const footerFor = (landscape) => applyTemplate(footerTemplate, {
...values,
pageWidth: landscape ? '297mm' : '210mm',
@@ -280,6 +272,18 @@ async function main() {
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) => {
@@ -360,7 +364,17 @@ async function applyProportionalWidths(page, textMm) {
+ '.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: every single-column cell's rendered width in one probe layout.
// 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);
return text + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + 1;
};
const measure = (table, mode) => {
const holder = document.createElement('div');
holder.className = `delivery-document width-probe ${mode}`;
@@ -370,7 +384,7 @@ async function applyProportionalWidths(page, textMm) {
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: cell.getBoundingClientRect().width / mm, head: cell.tagName === 'TH' });
if (cell.colSpan === 1) (columns[index] ??= []).push({ width: contentWidth(cell) / mm, head: cell.tagName === 'TH' });
index += cell.colSpan;
}
}
@@ -424,6 +438,13 @@ async function applyProportionalWidths(page, textMm) {
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');