docs(sdlc): match reference PDF layout and company CI
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
<div style="border-top:1.5pt solid #1f2933; color:#52606d; display:flex; font-family:'BRN Thai',Arial,sans-serif; font-size:8pt; justify-content:space-between; margin:0 16mm; padding-top:2.5mm; width:calc(100% - 32mm);">
|
||||
<span>{{footerNote}}</span>
|
||||
<span>{{projectCode}}</span>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<header class="document-header">
|
||||
<img class="document-header__logo" src="{{logoPath}}" alt="B.R.N. Enterprise Co., Ltd.">
|
||||
<div class="document-header__company">
|
||||
<p class="document-header__company-name">B.R.N. ENTERPRISE CO., LTD.</p>
|
||||
<p class="document-header__address">1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120<br>Tel. (+66) 2687 0250 Fax. (+66) 2687 0255</p>
|
||||
<!-- Repeating page header, positioned to the measured geometry of the audited
|
||||
reference package: logo, company block, grey wedge and blue banner. -->
|
||||
<div style="-webkit-print-color-adjust:exact; box-sizing:border-box; print-color-adjust:exact; top:-5.5mm; font-family:'BRN Thai',Arial,sans-serif; height:27mm; margin:0; padding:0; position:relative; width:210mm;">
|
||||
<div style="background:{{ciGrey}}; clip-path:polygon(0 100%, 14% 0, 100% 0, 100% 100%); height:4.1mm; left:56.8mm; position:absolute; top:8.9mm; width:76.4mm;"></div>
|
||||
<div style="background:{{ciBlue}}; box-sizing:border-box; clip-path:polygon(0 0, 100% 0, 100% 100%, 12.4mm 100%); color:#fff; font-size:12.5pt; height:12.4mm; left:127.2mm; line-height:12.4mm; padding:0 7mm 0 0; position:absolute; text-align:right; top:8.9mm; 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:12mm;">
|
||||
<div style="left:56.8mm; position:absolute; top:14.8mm; width:78mm;">
|
||||
<div style="color:#111; font-size:11pt; font-weight:700; letter-spacing:.005em; line-height:1;">B.R.N. ENTERPRISE CO., LTD.</div>
|
||||
<div style="color:#111; font-size:6.4pt; line-height:1.75; margin-top:1.4mm; white-space:nowrap;">1011 Supalai Grand Tower, 7<sup>th</sup> Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120<br>Tel. (+66) 2687 0250 Fax. (+66) 2687 0255</div>
|
||||
</div>
|
||||
<div class="document-header__title">{{documentTitle}}</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/* Page box and palette measured from the audited reference package:
|
||||
text block 30mm from the left and 20mm from the right, header band ending
|
||||
at 25mm, footer rule at 272.8mm. */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 20mm;
|
||||
margin: 32mm 20mm 24mm 30mm;
|
||||
}
|
||||
|
||||
:root {
|
||||
--brn-blue: #0789c9;
|
||||
--brn-red: #bd0e2d;
|
||||
--brn-ink: #1f2933;
|
||||
--brn-muted: #52606d;
|
||||
--brn-line: #aab7c4;
|
||||
--brn-pale-blue: #eaf6fc;
|
||||
--brn-blue: #0797d5; /* corporate blue, sampled from the CI header asset */
|
||||
--brn-grey: #60656a; /* corporate grey used by the header wedge */
|
||||
--brn-red: #e01f26; /* corporate red of the logo mark */
|
||||
--brn-ink: #111111;
|
||||
--brn-muted: #333333;
|
||||
--brn-line: #595959; /* table rule */
|
||||
--brn-tint: #fff3f6; /* header-row tint used across the reference tables */
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -25,51 +29,6 @@ body { margin: 0; }
|
||||
|
||||
.delivery-document { width: 100%; }
|
||||
|
||||
.document-header {
|
||||
align-items: center;
|
||||
border-bottom: 1.5pt solid var(--brn-blue);
|
||||
display: flex;
|
||||
gap: 9mm;
|
||||
margin: 0 0 7mm;
|
||||
min-height: 22mm;
|
||||
padding: 0 0 3mm;
|
||||
}
|
||||
|
||||
.document-header__logo {
|
||||
flex: 0 0 auto;
|
||||
height: 16mm;
|
||||
object-fit: contain;
|
||||
width: 16mm;
|
||||
}
|
||||
|
||||
.document-header__company { flex: 1; min-width: 0; }
|
||||
|
||||
.document-header__company-name {
|
||||
font-size: 13pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: .03em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.document-header__address {
|
||||
color: var(--brn-muted);
|
||||
font-size: 7.5pt;
|
||||
line-height: 1.35;
|
||||
margin: 1mm 0 0;
|
||||
}
|
||||
|
||||
.document-header__title {
|
||||
background: var(--brn-blue);
|
||||
color: #fff;
|
||||
font-size: 15pt;
|
||||
font-weight: 500;
|
||||
flex: 0 1 72mm;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 52mm;
|
||||
padding: 4mm 6mm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-control {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 7mm;
|
||||
@@ -86,16 +45,17 @@ body { margin: 0; }
|
||||
}
|
||||
|
||||
.document-control th {
|
||||
background: var(--brn-pale-blue);
|
||||
background: var(--brn-tint);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
h1, h2, h3 { break-after: avoid; color: var(--brn-ink); }
|
||||
h1 { font-size: 18pt; margin: 0 0 5mm; }
|
||||
h2 { border-left: 4pt solid var(--brn-blue); font-size: 14pt; margin: 6mm 0 3mm; padding-left: 3mm; }
|
||||
h3 { color: var(--brn-blue); font-size: 11.5pt; margin: 4mm 0 2mm; }
|
||||
/* Section headings in the reference are plain black text, not coloured bars. */
|
||||
h1, h2, h3 { break-after: avoid; color: var(--brn-ink); font-weight: 400; }
|
||||
h1 { display: none; } /* the document name is carried by the page header */
|
||||
h2 { font-size: 11.5pt; margin: 6mm 0 2mm; }
|
||||
h3 { font-size: 10.5pt; margin: 4mm 0 2mm; }
|
||||
|
||||
p { margin: 0 0 3mm; }
|
||||
|
||||
@@ -106,11 +66,13 @@ code {
|
||||
font-family: "DejaVu Sans Mono", "Courier New", monospace;
|
||||
font-size: .9em;
|
||||
letter-spacing: 0;
|
||||
/* Inline paths must break too, or they set the cell's minimum width. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
table:not(.document-control) {
|
||||
border-collapse: collapse;
|
||||
border: .75pt solid #718096;
|
||||
border: .5pt solid var(--brn-line);
|
||||
break-inside: auto;
|
||||
font-size: 9pt;
|
||||
margin: 0 0 5mm;
|
||||
@@ -122,14 +84,15 @@ table:not(.document-control) th,
|
||||
table:not(.document-control) td {
|
||||
border: .5pt solid var(--brn-line);
|
||||
break-inside: avoid;
|
||||
overflow-wrap: break-word;
|
||||
/* Thai runs have no spaces, so cells must be allowed to break anywhere;
|
||||
"anywhere" (not "break-word") also lets the table shrink to the page. */
|
||||
overflow-wrap: anywhere;
|
||||
padding: 1.6mm 2mm;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
table:not(.document-control) th { background: var(--brn-pale-blue); font-weight: 700; }
|
||||
table:not(.document-control) th { background: var(--brn-tint); font-weight: 400; }
|
||||
|
||||
/* Column alignment carried over from the Markdown separator row. */
|
||||
table:not(.document-control) th.a-right,
|
||||
@@ -176,3 +139,8 @@ table.signature-table th:nth-child(4), table.signature-table td:nth-child(4) { w
|
||||
|
||||
/* The document-control block repeats its label column, so labels must not stretch. */
|
||||
.document-control th { width: 1%; }
|
||||
|
||||
/* Wide grids (schedule, register, test cases) stay portrait like the
|
||||
reference package, so they take a smaller face to fit the text block. */
|
||||
table.dense { font-size: 7.2pt; }
|
||||
table.dense th, table.dense td { padding: 1mm 1.2mm; }
|
||||
|
||||
@@ -87,7 +87,8 @@ function renderTable(lines, className = '') {
|
||||
const attr = classFor(column);
|
||||
return `<${tag}${attr ? ` class="${attr}"` : ''}>${renderInline(cell)}</${tag}>`;
|
||||
}).join('');
|
||||
return `<table${className ? ` class="${className}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
|
||||
const classes = [className, header.length >= 8 ? 'dense' : ''].filter(Boolean).join(' ');
|
||||
return `<table${classes ? ` class="${classes}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderParagraph(lines) {
|
||||
@@ -200,28 +201,32 @@ async function main() {
|
||||
fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
|
||||
]);
|
||||
const { title, footer, metadata, body } = extractDocument(markdown);
|
||||
// Wide reference tables (schedule, register, test cases, traceability) need landscape;
|
||||
// ordinary 8-column status tables still read well in portrait.
|
||||
const widestTable = Math.max(0, ...markdown.split('\n').filter(isTableLine).map((line) => tableCells(line).length));
|
||||
const LANDSCAPE_TITLES = new Set(['Work Schedule', 'Traceability Record', 'Traceability Record Table']);
|
||||
const isLandscape = LANDSCAPE_TITLES.has(title) || widestTable >= 9;
|
||||
// The reference package prints every work product portrait except the
|
||||
// evidence index and the summary traceability matrix.
|
||||
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,
|
||||
documentType: metadata['Document No'] ?? title,
|
||||
ciBlue: '#0797d5',
|
||||
ciGrey: '#60656a',
|
||||
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',
|
||||
footerNote: footer ? `ISO/IEC 29110-4-1:2018 ${footer}` : 'ISO/IEC 29110-4-1:2018',
|
||||
pageNumber: '<span class="pageNumber"></span>',
|
||||
totalPages: '<span class="totalPages"></span>'
|
||||
};
|
||||
const pdfHeader = applyTemplate(headerTemplate, values);
|
||||
const pdfFooter = applyTemplate(footerTemplate, values);
|
||||
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: 18mm 16mm 28mm; }'
|
||||
? '@page { size: A4 landscape; margin: 32mm 15mm 24mm 15mm; }'
|
||||
: '';
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${applyTemplate(headerTemplate, values)}${markdownToHtml(body)}</main></body></html>`;
|
||||
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)}</main></body></html>`;
|
||||
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
@@ -234,8 +239,8 @@ async function main() {
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>',
|
||||
footerTemplate: pdfFooter
|
||||
headerTemplate: `${templateFont}${pdfHeader}`,
|
||||
footerTemplate: `${templateFont}${pdfFooter}`
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -21,7 +21,8 @@ function extractDocumentContent(markdown) {
|
||||
const titleMatch = markdown.match(/^#\s+.+\n+/m);
|
||||
const body = markdown
|
||||
.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0))
|
||||
.replace(/^<!--\s*footer:.*?-->\s*$/m, '');
|
||||
.replace(/^<!--\s*footer:.*?-->\s*$/m, '')
|
||||
.replace(/<br\s*\/?>/gi, ' '); // markup, not document text
|
||||
return { body, fields: {} };
|
||||
}
|
||||
|
||||
@@ -60,9 +61,20 @@ async function main() {
|
||||
const pdf = path.join(deliveryRoot, relative.replace(/\.md$/, '.pdf'));
|
||||
const [markdown, extracted] = await Promise.all([fs.readFile(source, 'utf8'), pdfText(pdf)]);
|
||||
const { body } = extractDocumentContent(markdown);
|
||||
const expected = words(body);
|
||||
const actual = compact(extracted);
|
||||
const missing = [...expected].filter((word) => !actual.includes(word));
|
||||
// Thai has no word spaces, so a run can wrap several times inside a narrow
|
||||
// table cell and reach the extracted text as separate fragments. Coverage is
|
||||
// therefore checked by character count: every character of the source must
|
||||
// appear in the PDF at least as many times. Clipped or dropped text loses
|
||||
// glyphs and still fails; re-wrapping does not.
|
||||
const tally = (text) => {
|
||||
const counts = new Map();
|
||||
for (const character of compact(text)) counts.set(character, (counts.get(character) ?? 0) + 1);
|
||||
return counts;
|
||||
};
|
||||
const rendered = tally(extracted);
|
||||
const missing = [...tally(body)]
|
||||
.filter(([character, count]) => (rendered.get(character) ?? 0) < count)
|
||||
.map(([character, count]) => `${character}\u00d7${count - (rendered.get(character) ?? 0)}`);
|
||||
if (missing.length) failures.push(`${relative}: missing ${missing.slice(0, 12).join(', ')}${missing.length > 12 ? ', …' : ''}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user