docs(sdlc): column widths proportional to content

This commit is contained in:
Thanakorn
2026-10-02 15:56:39 +07:00
parent 11e40cba57
commit 98a80e0878
113 changed files with 107 additions and 3 deletions
+107 -3
View File
@@ -14,11 +14,11 @@ const escapeHtml = (value) => value
.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;
// short Thai dates (16 ก.ค. 69, 10–14 ส.ค. 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 UNBREAKABLE = new RegExp(`\\b(?:TC-)?[A-Z]{2,4}-?\\d{2,3}(?:[:.]\\d{3})?\\b|\\d{1,2}(?:–\\d{1,2})? (?:${THAI_MONTHS}) \\d{2,4}`, 'g');
const renderInline = (value) => escapeHtml(value)
.replace(UNBREAKABLE, '<span class="nobr">$&</span>')
@@ -278,8 +278,10 @@ async function main() {
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 clipped = await applyProportionalWidths(page, textMm);
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) => {
const main = document.querySelector('main.delivery-document');
main.style.width = `${mm}mm`; // the screen viewport is wider than the printed text block
@@ -340,4 +342,106 @@ async function tableWidthNeededMm(page) {
}, COLUMN_CAP_MM);
}
/**
* Column widths proportional to content. Each column's share of the text width
* follows the average one-line width of its cells, but it never drops below the
* narrowest width that keeps its words and IDs whole, and never grows beyond its
* longest line; the widths are then fixed so the browser does not redistribute
* them. Returns the IDs or dates that still stick out of their cell.
*/
async function applyProportionalWidths(page, textMm) {
return page.evaluate(({ availMm, SHORT_COLUMN_MM }) => {
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; table-layout: auto !important; } '
+ '.width-probe.one-line th, .width-probe.one-line td { white-space: nowrap !important; } '
+ '.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.
const measure = (table, mode) => {
const holder = document.createElement('div');
holder.className = `delivery-document width-probe ${mode}`;
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] ??= []).push({ width: cell.getBoundingClientRect().width / mm, head: cell.tagName === 'TH' });
index += cell.colSpan;
}
}
holder.remove();
return columns;
};
const tables = [...main.querySelectorAll('table:not(.document-control):not(.signature-table)')];
for (const table of tables) {
const line = measure(table, 'one-line');
const narrow = measure(table, 'narrowest');
const n = line.length;
if (n < 2 || [...line].some((c) => !c)) continue;
const longest = line.map((c) => Math.max(...c.map((x) => x.width)));
let floor = narrow.map((c, i) => Math.min(Math.max(...c.map((x) => x.width)), longest[i]));
// Short columns (IDs, dates, statuses) stay on one line when the page allows;
// the prose columns then share what is left in proportion to their content.
const oneLine = floor.map((f, i) => (longest[i] <= SHORT_COLUMN_MM ? longest[i] : f));
if (oneLine.reduce((a, b) => a + b, 0) < availMm) floor = oneLine;
const weight = line.map((c) => {
const body = c.filter((x) => !x.head);
const cells = body.length ? body : c;
return Math.max(1, cells.reduce((sum, x) => sum + x.width, 0) / cells.length);
});
let widths;
const floorSum = floor.reduce((a, b) => a + b, 0);
if (floorSum >= availMm) {
widths = floor.map((f) => (f * availMm) / floorSum);
} else {
// Water-filling: share by weight, pin columns that hit their floor or longest line, repeat.
const pinned = new Array(n).fill(null);
for (let changed = true; changed;) {
changed = false;
const free = availMm - pinned.reduce((a, w) => a + (w ?? 0), 0);
const open = [...Array(n).keys()].filter((i) => pinned[i] === null);
if (!open.length) break;
const total = open.reduce((a, i) => a + weight[i], 0);
for (const i of open) {
if ((free * weight[i]) / total < floor[i]) { pinned[i] = floor[i]; changed = true; }
}
if (changed) continue;
for (const i of open) {
if ((free * weight[i]) / total > longest[i]) { pinned[i] = longest[i]; changed = true; }
}
if (!changed) for (const i of open) pinned[i] = (free * weight[i]) / total;
}
widths = pinned;
// Every column fits on one line with room to spare: spread the rest by weight.
const used = widths.reduce((a, b) => a + b, 0);
if (used < availMm) {
const total = weight.reduce((a, b) => a + b, 0);
widths = widths.map((w, i) => w + ((availMm - used) * weight[i]) / total);
}
}
const colgroup = document.createElement('colgroup');
for (const w of widths) {
const col = document.createElement('col');
col.style.width = `${((w / availMm) * 100).toFixed(2)}%`;
colgroup.append(col);
}
table.prepend(colgroup);
table.style.tableLayout = 'fixed';
}
probe.remove();
// An ID or date wider than its column would print over the next cell.
main.style.width = `${availMm}mm`;
const clipped = [...main.querySelectorAll('td .nobr, th .nobr')]
.filter((span) => span.getBoundingClientRect().right > span.closest('td, th').getBoundingClientRect().right + 0.5)
.map((span) => span.textContent);
main.style.width = '';
return [...new Set(clipped)];
}, { availMm: textMm, SHORT_COLUMN_MM: 35 });
}
main().catch((error) => { console.error(error); process.exit(1); });