From bbd9dcfb5aa78e93998d3395986a3dc50524a715 Mon Sep 17 00:00:00 2001
From: Ruslan Bakiev <572431+veikab@users.noreply.github.com>
Date: Mon, 4 May 2026 10:13:45 +0700
Subject: [PATCH] Add Typst PDF export for technical specification
---
.gitignore | 2 +
docs/scripts/build-typst-tz.mjs | 314 ++++++++++++++++++++++++++++++++
2 files changed, 316 insertions(+)
create mode 100644 docs/scripts/build-typst-tz.mjs
diff --git a/.gitignore b/.gitignore
index 4a7f73a..2628847 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@
.nitro
.cache
dist
+docs/.vitepress/cache
+docs/export
# Node dependencies
node_modules
diff --git a/docs/scripts/build-typst-tz.mjs b/docs/scripts/build-typst-tz.mjs
new file mode 100644
index 0000000..57a1f67
--- /dev/null
+++ b/docs/scripts/build-typst-tz.mjs
@@ -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 = /^$/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('
', ' ');
+ text = text.replaceAll('
', ' ');
+ text = text.replaceAll(' ', ' ');
+ 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`);