docs(sdlc): content-sized columns in Word, trace record with test cases, PSRs presented at minuted meetings

This commit is contained in:
Thanakorn
2026-10-02 16:35:31 +07:00
parent 64b1be55d8
commit 203b541c59
139 changed files with 322 additions and 170 deletions
@@ -74,7 +74,7 @@ table:not(.document-control) {
border-collapse: collapse;
border: .5pt solid var(--brn-line);
break-inside: auto;
font-size: 9pt;
font-size: 8.5pt;
margin: 0 0 5mm;
table-layout: auto;
width: 100%;
+75 -8
View File
@@ -41,7 +41,6 @@ HERE = Path(__file__).resolve().parent
# Page box measured from the audited reference package, as in sdlc-delivery.css.
BODY_TOP_MM = 32
FOOTER_FROM_BOTTOM_MM = 24.2 # footer rule at 272.8mm on a 297mm page
BAND_WIDTH_MM = 210
STANDARD = 'ISO/IEC 29110-4-1:2018'
RULE_SIZE = '4' # .5pt table rule, as printed
RULE_COLOUR = '595959' # --brn-line from sdlc-delivery.css
@@ -702,6 +701,68 @@ def build_table(document, rows):
return table
SIGNATURE_HEADER = tuple(letters(cell) for cell in ('ชื่อ', 'ตำแหน่ง', 'ลายเซ็น', 'วันที่'))
def apply_column_widths(document, sized):
"""Give each table the column widths the PDF was printed with.
pdf2docx writes its own grid, usually equal columns, so a table that reads
well in the PDF comes out cramped in Word. Tables are matched to the source
by their header row; pdf2docx repeats the header on each page's piece of a
long table, so every piece is matched. Returns the headers left unmatched.
"""
section = document.sections[0]
landscape = section.page_width > section.page_height
width = round(Mm(PRINTED_TEXT_WIDTH_MM[landscape]).twips)
by_header = {tuple(letters(cell) for cell in table['header']): table['widths'] for table in sized}
unmatched = []
for table in document.element.body.iter(qn('w:tbl')):
rows = table.findall(qn('w:tr'))
if not rows:
continue
header = tuple(letters(text_of(cell)) for cell in rows[0].findall(qn('w:tc')))
if len(header) < 2:
continue
percents = by_header.get(header)
if percents is None:
# The document-control block and the signature tables keep their own layout.
if header[0] != letters('Document No') and header != SIGNATURE_HEADER:
unmatched.append(' | '.join(header))
continue
total = sum(percents)
twips = [round(width * p / total) for p in percents]
properties = table.find(qn('w:tblPr'))
for tag, attributes in (('w:tblW', {'w:w': width, 'w:type': 'dxa'}), ('w:tblLayout', {'w:type': 'fixed'})):
node = properties.find(qn(tag))
if node is None:
node = OxmlElement(tag)
properties.append(node)
for name, value in attributes.items():
node.set(qn(name), str(value))
grid = table.find(qn('w:tblGrid'))
for column in list(grid):
grid.remove(column)
for value in twips:
column = OxmlElement('w:gridCol')
column.set(qn('w:w'), str(value))
grid.append(column)
for row in rows:
index = 0
for cell in row.findall(qn('w:tc')):
cell_properties = cell.get_or_add_tcPr()
span = cell_properties.find(qn('w:gridSpan'))
count = int(span.get(qn('w:val'))) if span is not None else 1
cell_width = cell_properties.find(qn('w:tcW'))
if cell_width is None:
cell_width = OxmlElement('w:tcW')
cell_properties.insert(0, cell_width)
cell_width.set(qn('w:w'), str(sum(twips[index:index + count])))
cell_width.set(qn('w:type'), 'dxa')
index += count
return unmatched
def restore_dropped_tables(document, markdown):
"""Put back tables the converter left out, where the source places them.
@@ -774,7 +835,7 @@ def install_letterhead(document, band, footer_tag):
header.paragraph_format.left_indent = -section.left_margin
header.paragraph_format.space_before = Pt(0)
header.paragraph_format.space_after = Pt(0)
header.add_run().add_picture(str(band), width=Mm(BAND_WIDTH_MM))
header.add_run().add_picture(str(band), width=section.page_width)
footer = section.footer.paragraphs[0]
drop_style(footer)
@@ -789,7 +850,7 @@ def install_letterhead(document, band, footer_tag):
style_run(footer.add_run(f'\t{footer_tag}'), 9)
def convert(pdf, source, band, footer_tag):
def convert(pdf, source, band, footer_tag, sized=()):
docx = pdf.with_suffix('.docx')
converter = Converter(str(pdf))
try:
@@ -806,6 +867,8 @@ def convert(pdf, source, band, footer_tag):
markdown = source.read_text(encoding='utf8')
restore_text(document, markdown, bullets)
restore_dropped_tables(document, markdown)
for header in apply_column_widths(document, sized):
print(f' WARNING {pdf.name}: no source widths for table "{header[:80]}"', file=sys.stderr)
for paragraph in bullets:
if paragraph.getparent() is not None:
make_bullet(document, paragraph)
@@ -816,9 +879,9 @@ def convert(pdf, source, band, footer_tag):
return docx
def render_bands(source_root, band_root):
def render_bands(source_root, band_root, pdf_root):
subprocess.run(
['node', str(HERE / 'render-ci-band.mjs'), str(source_root), str(band_root)],
['node', str(HERE / 'render-ci-band.mjs'), str(source_root), str(band_root), str(pdf_root)],
check=True
)
return json.loads((Path(band_root) / 'bands.json').read_text(encoding='utf8'))
@@ -831,16 +894,20 @@ def main(argv):
source_root, delivery_root = Path(argv[0]), Path(argv[1])
temporary = None
if len(argv) > 2:
bands = json.loads((Path(argv[2]) / 'bands.json').read_text(encoding='utf8'))
band_root = Path(argv[2])
bands = json.loads((band_root / 'bands.json').read_text(encoding='utf8'))
else:
temporary = tempfile.TemporaryDirectory()
bands = render_bands(source_root, temporary.name)
band_root = Path(temporary.name)
bands = render_bands(source_root, temporary.name, delivery_root)
failures = []
for index, (relative, band) in enumerate(sorted(bands.items()), start=1):
pdf = delivery_root / Path(relative).with_suffix('.pdf')
try:
docx = convert(pdf, source_root / relative, Path(band['band']), band['footer'])
widths = band_root / 'widths' / Path(relative).with_suffix('.json')
sized = json.loads(widths.read_text(encoding='utf8')) if widths.exists() else []
docx = convert(pdf, source_root / relative, Path(band['band']), band['footer'], sized)
print(f'[{index}/{len(bands)}] {docx.relative_to(delivery_root)}')
except Exception as error: # noqa: BLE001 - reported, not swallowed
failures.append((pdf, error))
+28
View File
@@ -0,0 +1,28 @@
// Page geometry shared by the PDF header (render-sdlc.mjs) and the Word header
// band (render-ci-band.mjs), so both draw the same letterhead for a page.
//
// Portrait is the audited reference layout. Landscape pages are 87mm wider: the
// grey ribbon stretches and the blue banner keeps its distance from the right
// edge, while the logo and company block stay where they are.
export const PAGE_MM = { portrait: 210, landscape: 297 };
const EXTRA_MM = PAGE_MM.landscape - PAGE_MM.portrait;
export function headerValues(title, landscape) {
const extra = landscape ? EXTRA_MM : 0;
return {
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`,
pageWidth: `${landscape ? PAGE_MM.landscape : PAGE_MM.portrait}mm`,
ribbonWidth: `${64.7 + extra}mm`,
bannerLeft: `${127.2 + extra}mm`
};
}
/** Orientation of a rendered PDF, read from its first /MediaBox. */
export function isLandscapePdf(bytes) {
const box = bytes.toString('latin1').match(/\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/);
if (!box) throw new Error('PDF has no /MediaBox');
const [x0, y0, x1, y1] = box.slice(1).map(Number);
return x1 - x0 > y1 - y0;
}
+17 -9
View File
@@ -7,12 +7,13 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { PAGE_MM, headerValues, isLandscapePdf } from './letterhead.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '../..');
const assets = path.join(here, 'assets');
export const BAND_MM = { width: 210, height: 27 };
export const BAND_MM = { height: 27 };
const SCALE = 3; // the band is drawn at 3x so it stays crisp in print
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
@@ -30,8 +31,9 @@ async function markdownFiles(directory, depth = 0) {
}
async function main() {
const [sourceRoot, outputRoot] = process.argv.slice(2);
if (!sourceRoot || !outputRoot) throw new Error('Usage: render-ci-band.mjs SOURCE_DIRECTORY OUTPUT_DIRECTORY');
// PDF_DIRECTORY holds the rendered PDFs: each band matches its PDF's orientation.
const [sourceRoot, outputRoot, pdfRoot] = process.argv.slice(2);
if (!sourceRoot || !outputRoot || !pdfRoot) throw new Error('Usage: render-ci-band.mjs SOURCE_DIRECTORY OUTPUT_DIRECTORY PDF_DIRECTORY');
const [template, logo] = await Promise.all([
fs.readFile(path.join(assets, 'header.html'), 'utf8'),
@@ -56,19 +58,25 @@ async function main() {
const title = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? 'BRN WMS';
const footer = markdown.match(/^<!--\s*footer:\s*(.+?)\s*-->\s*$/m)?.[1] ?? '';
if (!rendered.has(title)) {
const file = path.join(outputRoot, `${slug(title)}.png`);
const relative = path.relative(sourceRoot, source);
const pdf = path.join(pdfRoot, relative.replace(/\.md$/, '.pdf'));
const landscape = isLandscapePdf(await fs.readFile(pdf));
const key = `${title}|${landscape ? 'landscape' : 'portrait'}`;
const widthMm = landscape ? PAGE_MM.landscape : PAGE_MM.portrait;
if (!rendered.has(key)) {
const file = path.join(outputRoot, `${slug(title)}-${landscape ? 'landscape' : 'portrait'}.png`);
await page.setContent(
'<!doctype html><meta charset="utf-8"><body style="margin:0;background:#fff">' +
`<div id="band" style="width:${BAND_MM.width}mm;height:${BAND_MM.height}mm;overflow:hidden;position:relative">` +
`${applyTemplate(template, { ...values, documentTitle: title })}</div></body>`,
`<div id="band" style="width:${widthMm}mm;height:${BAND_MM.height}mm;overflow:hidden;position:relative">` +
`${applyTemplate(template, { ...values, ...headerValues(title, landscape) })}</div></body>`,
{ waitUntil: 'networkidle' }
);
await page.locator('#band').screenshot({ type: 'png', path: file });
rendered.set(title, file);
rendered.set(key, file);
}
bands[path.relative(sourceRoot, source)] = { title, footer, band: rendered.get(title) };
bands[relative] = { title, footer, band: rendered.get(key), widthMm };
}
} finally {
await browser.close();
+34 -13
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { headerValues } from './letterhead.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '../..');
@@ -101,7 +102,7 @@ function renderTable(lines, className = '') {
const attr = classFor(column);
return `<${tag}${attr ? ` class="${attr}"` : ''}>${renderInline(cell)}</${tag}>`;
}).join('');
const classes = [className, header.length >= 8 ? 'dense' : ''].filter(Boolean).join(' ');
const classes = [className, header.length >= 7 ? 'dense' : ''].filter(Boolean).join(' ');
return `<table${classes ? ` class="${classes}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => isGroup(row)
? `<tr class="group"><td colspan="${header.length}">${renderInline(row[0])}</td></tr>`
: `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
@@ -233,9 +234,6 @@ async function main() {
const LANDSCAPE_TITLES = new Set(['List of Evidence', 'Traceability Record Table']);
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
@@ -247,13 +245,7 @@ async function main() {
pageNumber: '<span class="pageNumber"></span>',
totalPages: '<span class="totalPages"></span>'
};
// 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 headerFor = (landscape) => applyTemplate(headerTemplate, { ...values, ...headerValues(title, landscape) });
const footerFor = (landscape) => applyTemplate(footerTemplate, {
...values,
pageWidth: landscape ? '297mm' : '210mm',
@@ -280,6 +272,18 @@ async function main() {
if (landscape) await page.setContent(htmlFor(true), { waitUntil: 'networkidle' });
const textMm = landscape ? 267 : PORTRAIT_TEXT_MM;
const clipped = await applyProportionalWidths(page, textMm);
// The Word copy is rebuilt from the PDF by pdf2docx, which loses these widths;
// the converter reads them back from this file (see convert-to-docx.py).
if (process.env.SDLC_WIDTHS_FILE) {
const sized = await page.evaluate(() => [...document.querySelectorAll('main table')]
.filter((table) => table.querySelector('colgroup'))
.map((table) => ({
header: [...table.tHead.rows[0].cells].map((cell) => cell.textContent.trim()),
widths: [...table.querySelectorAll('colgroup col')].map((col) => parseFloat(col.style.width))
})));
await fs.mkdir(path.dirname(process.env.SDLC_WIDTHS_FILE), { recursive: true });
await fs.writeFile(process.env.SDLC_WIDTHS_FILE, `${JSON.stringify(sized)}\n`);
}
for (const what of clipped) console.error(`WARNING ${path.basename(source)}: ${what} does not fit its column`);
// Unbreakable IDs can make a table wider than the page; say so rather than print it clipped.
const overflowMm = await page.evaluate((mm) => {
@@ -360,7 +364,17 @@ async function applyProportionalWidths(page, textMm) {
+ '.width-probe.narrowest table { width: 1px !important; } '
+ '.width-probe.narrowest th, .width-probe.narrowest td { overflow-wrap: normal !important; }';
document.head.append(probe);
// Per column: every single-column cell's rendered width in one probe layout.
// Per column: the width of every single-column cell's own content (plus its
// padding) in one probe layout. A cell's box is as wide as its whole column,
// so the content is measured with a range rather than the cell's rectangle.
const contentWidth = (cell) => {
const range = document.createRange();
range.selectNodeContents(cell);
const rects = [...range.getClientRects()];
const text = rects.length ? Math.max(...rects.map((r) => r.right)) - Math.min(...rects.map((r) => r.left)) : 0;
const style = getComputedStyle(cell);
return text + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + 1;
};
const measure = (table, mode) => {
const holder = document.createElement('div');
holder.className = `delivery-document width-probe ${mode}`;
@@ -370,7 +384,7 @@ async function applyProportionalWidths(page, textMm) {
for (const row of holder.querySelector('table').rows) {
let index = 0;
for (const cell of row.cells) {
if (cell.colSpan === 1) (columns[index] ??= []).push({ width: cell.getBoundingClientRect().width / mm, head: cell.tagName === 'TH' });
if (cell.colSpan === 1) (columns[index] ??= []).push({ width: contentWidth(cell) / mm, head: cell.tagName === 'TH' });
index += cell.colSpan;
}
}
@@ -424,6 +438,13 @@ async function applyProportionalWidths(page, textMm) {
widths = widths.map((w, i) => w + ((availMm - used) * weight[i]) / total);
}
}
// Topic columns (CR Topic, SRS Topic, Unit Topic) share their combined width equally.
const heads = [...(table.tHead?.rows[0]?.cells ?? [])].map((cell) => cell.textContent.trim());
const topics = heads.map((h, i) => (/Topic$/i.test(h) ? i : -1)).filter((i) => i >= 0);
if (topics.length > 1 && heads.length === n) {
const even = topics.reduce((sum, i) => sum + widths[i], 0) / topics.length;
if (topics.every((i) => even >= floor[i])) for (const i of topics) widths[i] = even;
}
const colgroup = document.createElement('colgroup');
for (const w of widths) {
const col = document.createElement('col');