448 lines
21 KiB
JavaScript
448 lines
21 KiB
JavaScript
import fs from 'node:fs/promises';
|
||
import { readFileSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { chromium } from 'playwright';
|
||
|
||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||
const root = path.resolve(here, '../..');
|
||
const assets = path.join(here, 'assets');
|
||
|
||
const escapeHtml = (value) => value
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>');
|
||
|
||
// Requirement, unit, test and issue IDs (CR01:002, TC-UN08.002, ISS-001) and
|
||
// 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}(?:–\\d{1,2})? (?:${THAI_MONTHS}) \\d{2,4}`, 'g');
|
||
|
||
const renderInline = (value) => escapeHtml(value)
|
||
.replace(UNBREAKABLE, '<span class="nobr">$&</span>')
|
||
// A literal <br> is the only markup allowed through, so a table cell can list
|
||
// several files on separate lines the way the reference package does.
|
||
.replaceAll('<br>', '<br>')
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||
.replace(/_{3,}/g, '<span class="approval-block__line"></span>')
|
||
.replace(/\\([\\*_`~])/g, '$1');
|
||
|
||
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
|
||
|
||
function isTableLine(line) {
|
||
return /^\|.*\|\s*$/.test(line.trim());
|
||
}
|
||
|
||
function tableCells(line) {
|
||
return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
|
||
}
|
||
|
||
function isSeparator(cells) {
|
||
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
||
}
|
||
|
||
const TIGHT_COLUMN_MAX = 12;
|
||
|
||
function columnAlignment(separatorCells) {
|
||
return separatorCells.map((cell) => {
|
||
const left = cell.startsWith(':');
|
||
const right = cell.endsWith(':');
|
||
if (left && right) return 'a-center';
|
||
if (right) return 'a-right';
|
||
return '';
|
||
});
|
||
}
|
||
|
||
function isControlBlock(lines) {
|
||
return tableCells(lines[0] ?? '')[0] === 'Document No';
|
||
}
|
||
|
||
// The document-control block is a label/value grid, not a data table: odd cells are
|
||
// labels, even cells are values, and a row with one pair spans the full width.
|
||
function renderControlBlock(lines) {
|
||
const rows = lines.map(tableCells);
|
||
const body = rows.map((cells) => {
|
||
const pairs = [];
|
||
for (let i = 0; i < cells.length; i += 2) pairs.push([cells[i], cells[i + 1] ?? '']);
|
||
const span = pairs.length === 1 ? ' colspan="3"' : '';
|
||
return `<tr>${pairs.map(([label, value]) => `<th>${renderInline(label)}</th><td${span}>${renderInline(value)}</td>`).join('')}</tr>`;
|
||
}).join('');
|
||
return `<table class="document-control"><tbody>${body}</tbody></table>`;
|
||
}
|
||
|
||
function renderTable(lines, className = '') {
|
||
const rows = lines.map(tableCells);
|
||
const header = rows[0];
|
||
const hasSeparator = isSeparator(rows[1] ?? []);
|
||
const align = hasSeparator ? columnAlignment(rows[1]) : header.map(() => '');
|
||
const data = hasSeparator ? rows.slice(2) : rows.slice(1);
|
||
// A row with only its first cell filled is a group heading (CR01, SR06, UN04,
|
||
// WP 1.0 …): it spans the full width and does not size the columns. Signature
|
||
// tables keep their blank cells for signing.
|
||
const isGroup = (row) => className !== 'signature-table' && header.length >= 3
|
||
&& Boolean(row[0]) && row.slice(1).every((cell) => !cell);
|
||
|
||
// A column whose longest cell is short (IDs, dates, counts, statuses) is
|
||
// marked "tight" so auto table layout shrinks it to its content instead of
|
||
// handing every column an equal share of the page width.
|
||
const widest = header.map((_, column) => Math.max(
|
||
(header[column] ?? '').length,
|
||
...data.filter((row) => !isGroup(row)).map((row) => (row[column] ?? '').replace(/[`*]/g, '').length)
|
||
));
|
||
const classFor = (column) => [
|
||
align[column] ?? '',
|
||
widest[column] <= TIGHT_COLUMN_MAX ? 'tight' : ''
|
||
].filter(Boolean).join(' ');
|
||
|
||
const cells = (tag, row) => row.map((cell, column) => {
|
||
const attr = classFor(column);
|
||
return `<${tag}${attr ? ` class="${attr}"` : ''}>${renderInline(cell)}</${tag}>`;
|
||
}).join('');
|
||
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) => isGroup(row)
|
||
? `<tr class="group"><td colspan="${header.length}">${renderInline(row[0])}</td></tr>`
|
||
: `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
|
||
}
|
||
|
||
function renderParagraph(lines) {
|
||
return lines.map((line, index) => {
|
||
const trimmed = line.trimEnd();
|
||
const hasHardBreak = /\s{2,}$/.test(line);
|
||
const isApprovalField = /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(trimmed);
|
||
const separator = hasHardBreak || isApprovalField ? '<br>' : (index < lines.length - 1 ? ' ' : '');
|
||
return `${renderInline(trimmed)}${separator}`;
|
||
}).join('');
|
||
}
|
||
|
||
function markdownToHtml(markdown, baseDir) {
|
||
const lines = markdown.replaceAll('\r\n', '\n').split('\n');
|
||
const output = [];
|
||
let index = 0;
|
||
|
||
while (index < lines.length) {
|
||
const line = lines[index];
|
||
if (!line.trim()) { index += 1; continue; }
|
||
|
||
// A figure: , embedded so the PDF needs no external files.
|
||
const image = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
||
if (image) {
|
||
const data = readFileSync(path.resolve(baseDir, image[2])).toString('base64');
|
||
output.push(`<figure class="figure"><img src="data:image/png;base64,${data}" alt=""><figcaption>${renderInline(image[1])}</figcaption></figure>`);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (isTableLine(line)) {
|
||
const table = [];
|
||
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
|
||
if (isControlBlock(table)) { output.push(renderControlBlock(table)); continue; }
|
||
const previous = output[output.length - 1] ?? '';
|
||
// Keep a signature heading and its table together on one page.
|
||
if (/^<h2 class="signature-heading">/.test(previous)) {
|
||
output.push(`<div class="signature-block">${output.pop()}${renderTable(table, 'signature-table')}</div>`);
|
||
continue;
|
||
}
|
||
output.push(renderTable(table));
|
||
continue;
|
||
}
|
||
|
||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||
if (heading) {
|
||
const level = heading[1].length;
|
||
const signature = level === 2 && /^ผู้(จัดทำเอกสาร|ตรวจสอบเอกสาร|อนุมัติ)/.test(heading[2]);
|
||
const attr = signature ? ' class="signature-heading"' : '';
|
||
output.push(`<h${level}${attr}>${renderInline(heading[2])}</h${level}>`);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if (/^[-*]\s+/.test(line)) {
|
||
const items = [];
|
||
while (index < lines.length && /^[-*]\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^[-*]\s+/, ''))}</li>`);
|
||
output.push(`<ul>${items.join('')}</ul>`);
|
||
continue;
|
||
}
|
||
|
||
if (/^\d+\.\s+/.test(line)) {
|
||
const items = [];
|
||
while (index < lines.length && /^\d+\.\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^\d+\.\s+/, ''))}</li>`);
|
||
output.push(`<ol>${items.join('')}</ol>`);
|
||
continue;
|
||
}
|
||
|
||
const paragraph = [];
|
||
while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^!\[/.test(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index]) && !/^\d+\.\s+/.test(lines[index])) paragraph.push(lines[index++]);
|
||
const isApproval = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd()));
|
||
const html = `<p${isApproval ? ' class="approval-fields"' : ''}>${renderParagraph(paragraph)}</p>`;
|
||
// Keep a signatory's heading and fields on one page.
|
||
if (isApproval && /^<h3>/.test(output[output.length - 1] ?? '')) {
|
||
output.push(`<div class="approval-block__person">${output.pop()}${html}</div>`);
|
||
} else {
|
||
output.push(html);
|
||
}
|
||
}
|
||
|
||
return output.join('\n');
|
||
}
|
||
|
||
function extractDocument(markdown) {
|
||
const titleMatch = markdown.match(/^#\s+(.+)\n+/m);
|
||
const title = titleMatch?.[1] ?? 'BRN WMS';
|
||
const footer = markdown.match(/^<!--\s*footer:\s*(.+?)\s*-->\s*$/m)?.[1] ?? '';
|
||
const body = markdown
|
||
.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0))
|
||
.replace(/^<!--\s*footer:.*?-->\s*$/m, '');
|
||
|
||
// The control block carries the document identity used by the header and footer.
|
||
const metadata = {};
|
||
const lines = body.split('\n');
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
if (!isTableLine(lines[index])) continue;
|
||
const table = [];
|
||
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
|
||
if (!isControlBlock(table)) { index -= 1; continue; }
|
||
for (const cells of table.map(tableCells)) {
|
||
for (let i = 0; i < cells.length; i += 2) if (cells[i]) metadata[cells[i]] = cells[i + 1] ?? '';
|
||
}
|
||
break;
|
||
}
|
||
|
||
return { title, footer, metadata, body };
|
||
}
|
||
|
||
async function main() {
|
||
const [source, destination] = process.argv.slice(2);
|
||
if (!source || !destination) throw new Error('Usage: render-sdlc.mjs SOURCE.md DESTINATION.pdf');
|
||
|
||
const [markdown, css, headerTemplate, footerTemplate, logo, thaiFont] = await Promise.all([
|
||
fs.readFile(source, 'utf8'),
|
||
fs.readFile(path.join(assets, 'sdlc-delivery.css'), 'utf8'),
|
||
fs.readFile(path.join(assets, 'header.html'), 'utf8'),
|
||
fs.readFile(path.join(assets, 'footer.html'), 'utf8'),
|
||
fs.readFile(path.join(root, 'app/assets/images/logo.svg')),
|
||
fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
|
||
]);
|
||
const { title, footer, metadata, body } = extractDocument(markdown);
|
||
// Work products print portrait unless a table needs the width: the evidence
|
||
// index and the summary traceability matrix always do, and any other document
|
||
// turns landscape when one of its tables needs more than the portrait width
|
||
// (see tableWidthNeededMm below).
|
||
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
|
||
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',
|
||
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 footerFor = (landscape) => applyTemplate(footerTemplate, {
|
||
...values,
|
||
pageWidth: landscape ? '297mm' : '210mm',
|
||
textLeft: landscape ? '15mm' : '30mm',
|
||
textRight: landscape ? '15mm' : '20mm',
|
||
textWidth: landscape ? '267mm' : '160mm'
|
||
});
|
||
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${thaiFont.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
|
||
const templateFont = `<style>${fontFace}</style>`;
|
||
const content = markdownToHtml(body, path.dirname(source));
|
||
const htmlFor = (landscape) => {
|
||
const pageLayout = landscape ? '@page { size: A4 landscape; margin: 32mm 15mm 24mm 15mm; }' : '';
|
||
return `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${content}</main></body></html>`;
|
||
};
|
||
|
||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||
const browser = await chromium.launch({ headless: true });
|
||
try {
|
||
const page = await browser.newPage();
|
||
await page.setContent(htmlFor(false), { waitUntil: 'networkidle' });
|
||
const neededMm = await tableWidthNeededMm(page);
|
||
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' });
|
||
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
|
||
const over = Math.max(0, ...[...main.querySelectorAll('table')].map((table) => (table.scrollWidth - main.clientWidth) / (96 / 25.4)));
|
||
main.style.width = '';
|
||
return over;
|
||
}, textMm);
|
||
if (overflowMm > 1) console.error(`WARNING ${path.basename(source)}: a table is ${overflowMm.toFixed(0)}mm wider than the page`);
|
||
await page.pdf({
|
||
path: destination,
|
||
format: 'A4',
|
||
printBackground: true,
|
||
preferCSSPageSize: true,
|
||
displayHeaderFooter: true,
|
||
headerTemplate: `${templateFont}${headerFor(landscape)}`,
|
||
footerTemplate: `${templateFont}${footerFor(landscape)}`
|
||
});
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* True when a table needs more than the 160mm portrait text block. Each column
|
||
* is given its one-line width, capped at COLUMN_CAP_MM for prose columns that
|
||
* are meant to wrap; when those widths add up to more than the page, portrait
|
||
* would stack short values (IDs, dates, statuses) one word per line.
|
||
*/
|
||
const PORTRAIT_TEXT_MM = 160;
|
||
const COLUMN_CAP_MM = Number(process.env.SDLC_COLUMN_CAP_MM ?? 30);
|
||
async function tableWidthNeededMm(page) {
|
||
return page.evaluate((capMm) => {
|
||
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; } .width-probe th, .width-probe td { white-space: nowrap !important; }';
|
||
document.head.append(probe);
|
||
let widest = 0;
|
||
for (const table of main.querySelectorAll('table:not(.document-control)')) {
|
||
const holder = document.createElement('div');
|
||
holder.className = 'delivery-document width-probe';
|
||
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] = Math.max(columns[index] ?? 0, cell.getBoundingClientRect().width / mm);
|
||
index += cell.colSpan;
|
||
}
|
||
}
|
||
holder.remove();
|
||
widest = Math.max(widest, columns.reduce((sum, w) => sum + Math.min(w ?? 0, capMm), 0));
|
||
}
|
||
probe.remove();
|
||
return widest;
|
||
}, 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); });
|