build(sdlc): seed data, figures and rendered package for 23/09 feedback

This commit is contained in:
Thanakorn
2026-09-24 09:19:05 +07:00
parent 6c1d66d8de
commit be020e332b
146 changed files with 788 additions and 313 deletions
@@ -144,3 +144,8 @@ table.signature-table th:nth-child(4), table.signature-table td:nth-child(4) { w
reference package, so they take a smaller face to fit the text block. */
table.dense { font-size: 7.2pt; }
table.dense th, table.dense td { padding: 1mm 1.2mm; }
/* Figures in the Software Design: full column width, caption under the image. */
.delivery-document figure.figure { margin: 8pt 0 12pt; text-align: center; break-inside: avoid; page-break-inside: avoid; }
.delivery-document figure.figure img { max-width: 100%; max-height: 225mm; border: .5pt solid #bfbfbf; }
.delivery-document figure.figure figcaption { margin-top: 4pt; font-size: 9.5pt; font-weight: 700; }
+9 -5
View File
@@ -188,15 +188,19 @@ LOGO_MIN_PX = 100
def strip_logo_images(document):
"""Drop the letterhead logo left loose in the body.
The work products carry no figures of their own, but the converter draws
each bullet as a small image, so only the logo is removed - anything that
size would be furniture, anything smaller is a list marker.
The converter draws each bullet as a small image, so only the logo is
removed: it is square and at least LOGO_MIN_PX, whereas anything smaller is
a list marker. The Software Design figures are wider than they are tall,
so they are kept.
"""
def is_logo(image):
width, height = image.px_width, image.px_height
return max(width, height) >= LOGO_MIN_PX and 0.8 <= width / max(height, 1) <= 1.25
logos = {
relationship_id
for relationship_id, part in document.part.related_parts.items()
if getattr(part, 'image', None) is not None
and max(part.image.px_width, part.image.px_height) >= LOGO_MIN_PX
if getattr(part, 'image', None) is not None and is_logo(part.image)
}
removed = 0
+34
View File
@@ -0,0 +1,34 @@
// Rasterise the Software Design figures (scripts/sdlc-seed/figures.mjs) to PNG with the
// document font, so Thai labels print the same in the PDF and survive the Word conversion.
// Usage: node scripts/sdlc-delivery/render-figures.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { FIGURES } from '../sdlc-seed/figures.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const output = path.resolve(here, '../sdlc-seed/figures');
async function main() {
const font = await fs.readFile(path.join(here, 'assets/LeelawadeeUI.ttf'));
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${font.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
await fs.mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const page = await (await browser.newContext({ deviceScaleFactor: 2 })).newPage();
for (const [file, , draw] of Object.values(FIGURES)) {
const svg = draw();
const [, w, h] = svg.match(/width="(\d+)" height="(\d+)"/);
await page.setViewportSize({ width: Number(w), height: Number(h) });
await page.setContent(`<!doctype html><html><head><meta charset="utf-8"><style>${fontFace} html,body{margin:0}</style></head><body>${svg}</body></html>`);
await page.evaluate(() => document.fonts.ready);
await page.locator('svg').first().screenshot({ path: path.join(output, `${file}.png`) });
console.log(`Rendered figures/${file}.png`);
}
} finally {
await browser.close();
}
}
main().catch((error) => { console.error(error); process.exit(1); });
+13 -3
View File
@@ -1,4 +1,5 @@
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';
@@ -101,7 +102,7 @@ function renderParagraph(lines) {
}).join('');
}
function markdownToHtml(markdown) {
function markdownToHtml(markdown, baseDir) {
const lines = markdown.replaceAll('\r\n', '\n').split('\n');
const output = [];
let index = 0;
@@ -110,6 +111,15 @@ function markdownToHtml(markdown) {
const line = lines[index];
if (!line.trim()) { index += 1; continue; }
// A figure: ![caption](relative/path.png), 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++]);
@@ -149,7 +159,7 @@ function markdownToHtml(markdown) {
}
const paragraph = [];
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++]);
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.
@@ -226,7 +236,7 @@ async function main() {
? '@page { size: A4 landscape; margin: 32mm 15mm 24mm 15mm; }'
: '';
const templateFont = `<style>${fontFace}</style>`;
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${markdownToHtml(body)}</main></body></html>`;
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${markdownToHtml(body, path.dirname(source))}</main></body></html>`;
await fs.mkdir(path.dirname(destination), { recursive: true });
const browser = await chromium.launch({ headless: true });
+2
View File
@@ -12,6 +12,8 @@ function extractDocumentContent(markdown) {
.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0))
.replace(/^<!--\s*footer:.*?-->\s*$/m, '')
.replace(/<br\s*\/?>/gi, ' ') // markup, not document text
// A figure prints its caption; the image path is markup.
.replace(/^!\[([^\]]*)\]\([^)]*\)\s*$/gm, '$1')
// List markers are drawn by the renderer, not stored as text: the browser
// paints them from <ol>/<ul> and Word from numbering.xml, so they are
// markup here too and must not be expected in the extracted text.