docs(sdlc): landscape for wide-table documents, keep IDs and dates whole

This commit is contained in:
Thanakorn
2026-10-02 15:42:18 +07:00
parent e2b457fa41
commit 11e40cba57
116 changed files with 101 additions and 21 deletions
+6 -5
View File
@@ -1,7 +1,8 @@
<!-- Repeating page footer: rule, standard reference at the left, document
abbreviation at the right, matching the audited reference package. -->
<div style="-webkit-print-color-adjust:exact; print-color-adjust:exact; font-family:'BRN Thai',Arial,sans-serif; margin:0; padding:0; position:relative; width:210mm;">
<div style="border-top:.75pt solid #111; left:30mm; position:absolute; top:0; width:160mm;"></div>
<div style="color:#111; font-size:9pt; left:30mm; position:absolute; top:1.2mm;">{{standard}}</div>
<div style="color:#111; font-size:9pt; position:absolute; right:20mm; text-align:right; top:1.2mm;">{{footerTag}}</div>
abbreviation at the right, matching the audited reference package.
It spans the text block: 30–190mm portrait, 15–282mm landscape. -->
<div style="-webkit-print-color-adjust:exact; print-color-adjust:exact; font-family:'BRN Thai',Arial,sans-serif; margin:0; padding:0; position:relative; width:{{pageWidth}};">
<div style="border-top:.75pt solid #111; left:{{textLeft}}; position:absolute; top:0; width:{{textWidth}};"></div>
<div style="color:#111; font-size:9pt; left:{{textLeft}}; position:absolute; top:1.2mm;">{{standard}}</div>
<div style="color:#111; font-size:9pt; position:absolute; right:{{textRight}}; text-align:right; top:1.2mm;">{{footerTag}}</div>
</div>
+4 -4
View File
@@ -1,12 +1,12 @@
<!-- Repeating page header reproducing the corporate identity of the audited
reference package. Every position is in millimetres measured from the
reference scan: logo at 39.4/11.0, grey ribbon 67.5–132.2, blue banner
127.2–197.2 with a 13.8mm diagonal, a rule under the company name from
127.2–197.2 (moved to the right edge on landscape pages) with a 13.8mm diagonal, a rule under the company name from
55.4mm, and the address block beneath it. The Latin face is Liberation
Sans, metric-compatible with the Arial of the reference. -->
<div style="-webkit-print-color-adjust:exact; box-sizing:border-box; print-color-adjust:exact; top:-5.5mm; font-family:'Liberation Sans',Arial,Helvetica,sans-serif; height:27mm; margin:0; padding:0; position:relative; width:210mm;">
<div style="background:{{ciGrey}}; box-sizing:border-box; clip-path:polygon(0 0, 100% 0, 100% 100%, 16mm 100%); height:4.3mm; left:67.5mm; position:absolute; top:8.9mm; width:64.7mm;"></div>
<div style="background:{{ciBlue}}; box-sizing:border-box; clip-path:polygon(0 0, 100% 0, 100% 100%, 13.8mm 100%); color:#fff; font-size:13.5pt; height:12.5mm; left:127.2mm; line-height:14.1mm; padding:0 7.8mm 0 0; position:absolute; text-align:right; top:8.6mm; white-space:nowrap; width:70mm;">{{documentTitle}}</div>
<div style="-webkit-print-color-adjust:exact; box-sizing:border-box; print-color-adjust:exact; top:-5.5mm; font-family:'Liberation Sans',Arial,Helvetica,sans-serif; height:27mm; margin:0; padding:0; position:relative; width:{{pageWidth}};">
<div style="background:{{ciGrey}}; box-sizing:border-box; clip-path:polygon(0 0, 100% 0, 100% 100%, 16mm 100%); height:4.3mm; left:67.5mm; position:absolute; top:8.9mm; width:{{ribbonWidth}};"></div>
<div style="background:{{ciBlue}}; box-sizing:border-box; clip-path:polygon(0 0, 100% 0, 100% 100%, 13.8mm 100%); color:#fff; font-size:{{titleSize}}; height:12.5mm; left:{{bannerLeft}}; line-height:14.1mm; padding:0 7.8mm 0 0; position:absolute; text-align:right; top:8.6mm; white-space:nowrap; width:70mm;">{{documentTitle}}</div>
<img src="{{logoPath}}" alt="" style="height:11.7mm; left:39.4mm; object-fit:contain; position:absolute; top:11mm; width:12.1mm;">
<div style="color:#111; font-family:'Liberation Sans Narrow','Arial Narrow',Arial,sans-serif; font-size:10.6pt; font-weight:700; left:56.8mm; line-height:1; position:absolute; top:16.5mm; white-space:nowrap;">B.R.N. ENTERPRISE CO., LTD.</div>
<div style="background:#111; height:.45mm; left:55.4mm; position:absolute; top:19.6mm; width:84.5mm;"></div>
@@ -150,3 +150,6 @@ table.dense th, table.dense td { padding: 1mm 1.2mm; }
.delivery-document figure.figure { margin: 8pt 0 12pt; text-align: center; break-inside: avoid; page-break-inside: avoid; }
.delivery-document figure.figure img { max-width: 100%; max-height: 225mm; border: .5pt solid #bfbfbf; }
.delivery-document figure.figure figcaption { margin-top: 4pt; font-size: 9.5pt; font-weight: 700; }
/* IDs and short dates stay on one line (see UNBREAKABLE in render-sdlc.mjs). */
.nobr { white-space: nowrap; overflow-wrap: normal; }
+88 -12
View File
@@ -13,7 +13,15 @@ const escapeHtml = (value) => value
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
// Requirement, unit, test and issue IDs (CR01:002, TC-UN08.002, ISS-001) and
// short Thai dates (16 ก.ค. 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} (?:${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>')
@@ -218,13 +226,16 @@ async function main() {
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.
// 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 isLandscape = LANDSCAPE_TITLES.has(title);
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
@@ -236,32 +247,97 @@ async function main() {
pageNumber: '<span class="pageNumber"></span>',
totalPages: '<span class="totalPages"></span>'
};
const pdfHeader = applyTemplate(headerTemplate, values);
const pdfFooter = applyTemplate(footerTemplate, values);
// 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 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 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>`;
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(html, { waitUntil: 'networkidle' });
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' });
// Unbreakable IDs can make a table wider than the page; say so rather than print it clipped.
const textMm = landscape ? 267 : PORTRAIT_TEXT_MM;
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}${pdfHeader}`,
footerTemplate: `${templateFont}${pdfFooter}`
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);
}
main().catch((error) => { console.error(error); process.exit(1); });