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); });
|
||||
|
||||
Reference in New Issue
Block a user