Add Typst PDF export for technical specification
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@
|
|||||||
.nitro
|
.nitro
|
||||||
.cache
|
.cache
|
||||||
dist
|
dist
|
||||||
|
docs/.vitepress/cache
|
||||||
|
docs/export
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
314
docs/scripts/build-typst-tz.mjs
Normal file
314
docs/scripts/build-typst-tz.mjs
Normal 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(' ', ' ');
|
||||||
|
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`);
|
||||||
Reference in New Issue
Block a user