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 <!-- Repeating page footer: rule, standard reference at the left, document
abbreviation at the right, matching the audited reference package. --> 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;"> It spans the text block: 30–190mm portrait, 15–282mm landscape. -->
<div style="border-top:.75pt solid #111; left:30mm; position:absolute; top:0; width:160mm;"></div> <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="color:#111; font-size:9pt; left:30mm; position:absolute; top:1.2mm;">{{standard}}</div> <div style="border-top:.75pt solid #111; left:{{textLeft}}; position:absolute; top:0; width:{{textWidth}};"></div>
<div style="color:#111; font-size:9pt; position:absolute; right:20mm; text-align:right; top:1.2mm;">{{footerTag}}</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> </div>
+4 -4
View File
@@ -1,12 +1,12 @@
<!-- Repeating page header reproducing the corporate identity of the audited <!-- Repeating page header reproducing the corporate identity of the audited
reference package. Every position is in millimetres measured from the 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 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 55.4mm, and the address block beneath it. The Latin face is Liberation
Sans, metric-compatible with the Arial of the reference. --> 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="-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:64.7mm;"></div> <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: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="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;"> <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="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> <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 { 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 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; } .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('<', '&lt;')
.replaceAll('>', '&gt;'); .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) 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 // 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. // several files on separate lines the way the reference package does.
.replaceAll('&lt;br&gt;', '<br>') .replaceAll('&lt;br&gt;', '<br>')
@@ -218,13 +226,16 @@ async function main() {
fs.readFile(path.join(assets, 'LeelawadeeUI.ttf')) fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
]); ]);
const { title, footer, metadata, body } = extractDocument(markdown); const { title, footer, metadata, body } = extractDocument(markdown);
// The reference package prints every work product portrait except the // Work products print portrait unless a table needs the width: the evidence
// evidence index and the summary traceability matrix. // 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 LANDSCAPE_TITLES = new Set(['List of Evidence', 'Traceability Record Table']);
const isLandscape = LANDSCAPE_TITLES.has(title);
const values = { const values = {
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`, logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
documentTitle: title, 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, documentType: metadata['Document No'] ?? title,
ciBlue: '#0797d5', ciBlue: '#0797d5',
ciGrey: '#8e8e8e', // ribbon grey measured from the audited reference documents ciGrey: '#8e8e8e', // ribbon grey measured from the audited reference documents
@@ -236,32 +247,97 @@ async function main() {
pageNumber: '<span class="pageNumber"></span>', pageNumber: '<span class="pageNumber"></span>',
totalPages: '<span class="totalPages"></span>' totalPages: '<span class="totalPages"></span>'
}; };
const pdfHeader = applyTemplate(headerTemplate, values); // Landscape pages are 87mm wider: the ribbon stretches and the banner keeps its distance from the right edge.
const pdfFooter = applyTemplate(footerTemplate, values); 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 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 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 }); await fs.mkdir(path.dirname(destination), { recursive: true });
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
try { try {
const page = await browser.newPage(); 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({ await page.pdf({
path: destination, path: destination,
format: 'A4', format: 'A4',
printBackground: true, printBackground: true,
preferCSSPageSize: true, preferCSSPageSize: true,
displayHeaderFooter: true, displayHeaderFooter: true,
headerTemplate: `${templateFont}${pdfHeader}`, headerTemplate: `${templateFont}${headerFor(landscape)}`,
footerTemplate: `${templateFont}${pdfFooter}` footerTemplate: `${templateFont}${footerFor(landscape)}`
}); });
} finally { } finally {
await browser.close(); 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); }); main().catch((error) => { console.error(error); process.exit(1); });

Some files were not shown because too many files have changed in this diff Show More