Fix SDLC delivery rendering and regenerate package

Nine layout defects were fixed in the generator so the delivery package is
reproducible from `scripts/build-sdlc-delivery.sh`.

CSS (scripts/sdlc-delivery/assets/sdlc-delivery.css):
- table-layout: fixed -> auto, so columns fit their content instead of each
  taking an equal share of the page width
- overflow-wrap: anywhere -> break-word, removing mid-word breaks such as
  "Representativ|es" and "communicati|on"
- break-inside: avoid on cells; text-align: left on th so headers match body
- new .a-right/.a-center/.tight column classes; ul/ol/li and code rules
- trimmed padding: @page bottom 28->20mm, h2 9->6mm, h3 6->4mm, cells
  2->1.6mm, .approval-fields; .document-control th shrinks to its label

Renderer (scripts/sdlc-delivery/render-sdlc.mjs):
- ordered lists emit <ol> instead of collapsing into a run-on paragraph
- column alignment read from the Markdown separator row (:---, ---:, :---:)
- columns whose longest cell is <= 12 chars marked "tight" to shrink to content
- underscore runs become styled signature rules
- each signatory's heading and fields wrapped so they cannot split across pages
- backslash escapes such as \* are unescaped

Regenerated sdlc-delivery/ with the fixed pipeline: 46 PDFs, 163 pages, both
content-coverage and A4-format verification passing. Work Schedule 14 -> 9
pages, Correction Register 11 -> 10, Acceptance Report 7 -> 6. The four
Meeting Records are included for the first time, and PDFs for renamed sources
follow their new paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thanakorn
2026-08-19 10:18:32 +07:00
co-authored by Claude Opus 5
parent 155cf783ae
commit 36ab46b9d1
48 changed files with 99 additions and 22 deletions
+51 -6
View File
@@ -14,7 +14,9 @@ const escapeHtml = (value) => value
const renderInline = (value) => escapeHtml(value)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
.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] ?? '');
@@ -30,11 +32,41 @@ 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 renderTable(lines, className = '') {
const rows = lines.map(tableCells);
const header = rows[0];
const data = isSeparator(rows[1] ?? []) ? rows.slice(2) : rows.slice(1);
const cells = (tag, row) => row.map((cell) => `<${tag}>${renderInline(cell)}</${tag}>`).join('');
const hasSeparator = isSeparator(rows[1] ?? []);
const align = hasSeparator ? columnAlignment(rows[1]) : header.map(() => '');
const data = hasSeparator ? rows.slice(2) : rows.slice(1);
// 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.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('');
return `<table${className ? ` class="${className}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
}
@@ -79,10 +111,23 @@ function markdownToHtml(markdown) {
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]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index])) paragraph.push(lines[index++]);
const approvalClass = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd())) ? ' class="approval-fields"' : '';
output.push(`<p${approvalClass}>${renderParagraph(paragraph)}</p>`);
while (index < lines.length && lines[index].trim() && !isTableLine(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');