Add Typst PDF export for technical specification

This commit is contained in:
Ruslan Bakiev
2026-05-04 10:13:45 +07:00
parent ac312a3a62
commit bbd9dcfb5a
2 changed files with 316 additions and 0 deletions

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@
.nitro .nitro
.cache .cache
dist dist
docs/.vitepress/cache
docs/export
# Node dependencies # Node dependencies
node_modules node_modules

View File

@@ -0,0 +1,314 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
// Usage: node docs/scripts/build-typst-tz.mjs
// Output: docs/export/tz-fregat.typ and docs/export/tz-fregat.pdf
const __dirname = dirname(fileURLToPath(import.meta.url));
const docsDir = join(__dirname, '..');
const sourceFile = join(docsDir, 'index.md');
const exportDir = join(docsDir, 'export');
const typstFile = join(exportDir, 'tz-fregat.typ');
const pdfFile = join(exportDir, 'tz-fregat.pdf');
const includePattern = /^<!--@include:\s+(.+?)\s*-->$/gm;
async function readMarkdownWithIncludes(filePath, seen = new Set()) {
if (seen.has(filePath)) {
throw new Error(`Circular include detected: ${filePath}`);
}
seen.add(filePath);
let content = await readFile(filePath, 'utf8');
const replacements = [];
for (const match of content.matchAll(includePattern)) {
const includePath = join(dirname(filePath), match[1]);
const includeContent = await readMarkdownWithIncludes(includePath, seen);
replacements.push([match[0], includeContent.trim()]);
}
for (const [needle, replacement] of replacements) {
content = content.replace(needle, replacement);
}
seen.delete(filePath);
return content;
}
function escapeTypstString(value) {
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
}
function escapeContentBlock(value) {
return value.replaceAll('[', '\\[').replaceAll(']', '\\]');
}
function inlineTypst(value) {
let text = value.trim();
text = text.replaceAll('<br>', ' ');
text = text.replaceAll('<br/>', ' ');
text = text.replaceAll('&nbsp;', ' ');
text = text.replace(/\*\*([^*]+)\*\*/g, '*$1*');
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, href) => {
if (href.startsWith('http://') || href.startsWith('https://')) {
return `#link("${escapeTypstString(href)}")[${escapeContentBlock(label)}]`;
}
return label;
});
return text;
}
function parseTableRow(line) {
const trimmed = line.trim();
const withoutEdges = trimmed.replace(/^\|/, '').replace(/\|$/, '');
return withoutEdges.split('|').map((cell) => inlineTypst(cell));
}
function isTableSeparator(line) {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
}
function renderTable(lines, startIndex) {
const rows = [];
let index = startIndex;
while (index < lines.length && /^\s*\|/.test(lines[index])) {
if (!isTableSeparator(lines[index])) {
rows.push(parseTableRow(lines[index]));
}
index += 1;
}
const columnCount = Math.max(...rows.map((row) => row.length));
const normalizedRows = rows.map((row) => {
if (row.length >= columnCount) {
return row;
}
return [...row, ...Array.from({ length: columnCount - row.length }, () => '')];
});
const cells = normalizedRows.flatMap((row, rowIndex) => row.map((cell) => {
const content = escapeContentBlock(cell);
return rowIndex === 0 ? `[*${content}*]` : `[${content}]`;
}));
const columns = Array.from({ length: columnCount }, () => '1fr').join(', ');
return {
nextIndex: index,
typst: [
'#text(size: 7.6pt)[',
' #table(',
` columns: (${columns}),`,
' stroke: rgb("#d0d7de"),',
' inset: 4pt,',
' align: horizon + left,',
` ${cells.join(',\n ')}`,
' )',
']',
'',
].join('\n'),
};
}
function renderImage(line) {
const match = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (!match) {
return null;
}
const [, alt, src] = match;
const imagePath = src.startsWith('/')
? `..${src.replace(/^\//, '/public/')}`
: src;
return [
'#figure(',
` image("${escapeTypstString(imagePath)}", width: 100%),`,
` caption: [${escapeContentBlock(alt)}],`,
')',
'',
].join('\n');
}
function markdownToTypst(markdown) {
const lines = markdown
.replace(/\r\n/g, '\n')
.replace(/^---[\s\S]*?---\n/, '')
.split('\n');
const output = [];
let inFence = false;
for (let index = 0; index < lines.length;) {
const line = lines[index];
const trimmed = line.trim();
if (trimmed.startsWith('```')) {
inFence = !inFence;
output.push(line);
index += 1;
continue;
}
if (inFence) {
output.push(line);
index += 1;
continue;
}
if (!trimmed) {
output.push('');
index += 1;
continue;
}
if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
const table = renderTable(lines, index);
output.push(table.typst);
index = table.nextIndex;
continue;
}
const image = renderImage(trimmed);
if (image) {
output.push(image);
index += 1;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
const title = inlineTypst(heading[2]);
if (level === 1 && title === 'Техническое задание на разработку программного продукта') {
index += 1;
continue;
}
if (level === 1 && output.some((item) => item.trim())) {
output.push('#pagebreak(weak: true)');
}
output.push(`${'='.repeat(level)} ${title}`);
output.push('');
index += 1;
continue;
}
const bullet = line.match(/^(\s*)-\s+(.+)$/);
if (bullet) {
output.push(`${bullet[1]}- ${inlineTypst(bullet[2])}`);
index += 1;
continue;
}
const ordered = line.match(/^(\s*)\d+\.\s+(.+)$/);
if (ordered) {
output.push(`${ordered[1]}+ ${inlineTypst(ordered[2])}`);
index += 1;
continue;
}
output.push(inlineTypst(line));
index += 1;
}
return output.join('\n');
}
function typstDocument(body) {
return `#set document(title: "Техническое задание на разработку программного продукта")
#set page(
paper: "a4",
margin: (left: 20mm, right: 18mm, top: 18mm, bottom: 18mm),
numbering: "1",
header: align(right)[#text(size: 8pt, fill: rgb("#667085"))[Личный кабинет Фрегат]],
footer: align(center)[#text(size: 8pt, fill: rgb("#667085"))[#context counter(page).display("1")]],
)
#set text(font: "Arial", size: 10pt, lang: "ru")
#set par(justify: true, leading: 0.58em, first-line-indent: 0pt)
#set list(indent: 13pt, body-indent: 5pt)
#show link: underline
#show heading.where(level: 1): set text(size: 15pt, weight: "bold")
#show heading.where(level: 2): set text(size: 12pt, weight: "bold")
#show heading.where(level: 3): set text(size: 10.5pt, weight: "bold")
#show heading: it => block(above: 1.15em, below: 0.55em, it)
#align(center)[
#text(size: 18pt, weight: "bold")[Техническое задание]
#v(5mm)
#text(size: 14pt)[на разработку программного продукта]
#v(3mm)
#text(size: 14pt, weight: "bold")[«Личный кабинет Фрегат»]
]
#v(12mm)
#align(center)[
#table(
columns: (40%, 60%),
stroke: rgb("#d0d7de"),
inset: 6pt,
[Заказчик], [ООО «Фрегат Групп»],
[Исполнитель], [ИП Бакиев Р.Ш.],
[Основание], [Договор №28/04-26ПО от 28.04.2026],
[Формат], [Техническое задание с учетом ГОСТ 19.201-78],
)
]
#pagebreak()
#outline(title: [Содержание], depth: 2, indent: auto)
#pagebreak()
${body}
`;
}
await mkdir(exportDir, { recursive: true });
const markdown = await readMarkdownWithIncludes(sourceFile);
const typst = typstDocument(markdownToTypst(markdown));
await writeFile(typstFile, typst, 'utf8');
const compileResult = spawnSync('typst', [
'compile',
'--root',
'.',
relative(docsDir, typstFile),
relative(docsDir, pdfFile),
], {
cwd: docsDir,
encoding: 'utf8',
});
if (compileResult.status !== 0) {
process.stderr.write(compileResult.stderr);
process.stderr.write(compileResult.stdout);
throw new Error('Typst PDF build failed');
}
process.stdout.write(`Generated ${relative(docsDir, typstFile)}\n`);
process.stdout.write(`Generated ${relative(docsDir, pdfFile)}\n`);