docs(sdlc): match reference PDF layout and company CI

This commit is contained in:
Thanakorn
2026-09-09 13:54:45 +07:00
parent 087e7b3958
commit 6255b31591
81 changed files with 149 additions and 156 deletions
+6 -3
View File
@@ -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>
+10 -7
View File
@@ -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 &nbsp; 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 &nbsp;&nbsp; Fax. (+66) 2687 0255</div>
</div>
<div class="document-header__title">{{documentTitle}}</div>
</header>
</div>
+29 -61
View File
@@ -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; }
+16 -11
View File
@@ -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();
+16 -4
View File
@@ -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 ? ', …' : ''}`);
}