docs(sdlc): column widths proportional to content
This commit is contained in:
@@ -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); });
|
||||
|
||||
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.
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