docs(sdlc): landscape for wide-table documents, keep IDs and dates whole
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -13,7 +13,15 @@ const escapeHtml = (value) => value
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
// 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('<br>', '<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); });
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user