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`);