Use Typst as source for specification PDF
This commit is contained in:
@@ -1,313 +1,25 @@
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import { dirname, join, relative } from 'node:path';
|
import { dirname, join, relative } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
// Usage: node docs/scripts/build-typst-tz.mjs
|
// Usage: node docs/scripts/build-typst-tz.mjs
|
||||||
// Output: docs/export/tz-fregat.typ and docs/export/tz-fregat.pdf
|
// Source: docs/tz-fregat.typ
|
||||||
|
// Output: docs/export/tz-fregat.pdf
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const docsDir = join(__dirname, '..');
|
const docsDir = join(__dirname, '..');
|
||||||
const sourceFile = join(docsDir, 'index.md');
|
const sourceFile = join(docsDir, 'tz-fregat.typ');
|
||||||
const exportDir = join(docsDir, 'export');
|
const exportDir = join(docsDir, 'export');
|
||||||
const typstFile = join(exportDir, 'tz-fregat.typ');
|
|
||||||
const pdfFile = join(exportDir, 'tz-fregat.pdf');
|
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('[', '\\[')
|
|
||||||
.replaceAll(']', '\\]')
|
|
||||||
.replaceAll('*', '\\*')
|
|
||||||
.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, '$1');
|
|
||||||
text = text.replaceAll('*', '\\*');
|
|
||||||
text = text.replaceAll('@', '\\@');
|
|
||||||
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: "Times New Roman", size: 10.5pt, lang: "ru")
|
|
||||||
#set par(justify: true, leading: 0.58em, first-line-indent: 0pt)
|
|
||||||
#set list(indent: 13pt, body-indent: 5pt)
|
|
||||||
#show link: underline
|
|
||||||
#show raw: set text(font: "Times New Roman")
|
|
||||||
#show heading.where(level: 1): set text(size: 16pt, weight: "bold")
|
|
||||||
#show heading.where(level: 2): set text(size: 13pt, weight: "bold")
|
|
||||||
#show heading.where(level: 3): set text(size: 11.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],
|
|
||||||
[Формат], [Техническое задание],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
#pagebreak()
|
|
||||||
|
|
||||||
#outline(title: [Содержание], depth: 2, indent: auto)
|
|
||||||
|
|
||||||
#pagebreak()
|
|
||||||
|
|
||||||
${body}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(exportDir, { recursive: true });
|
await mkdir(exportDir, { recursive: true });
|
||||||
|
|
||||||
const markdown = await readMarkdownWithIncludes(sourceFile);
|
|
||||||
const typst = typstDocument(markdownToTypst(markdown));
|
|
||||||
|
|
||||||
await writeFile(typstFile, typst, 'utf8');
|
|
||||||
|
|
||||||
const compileResult = spawnSync('typst', [
|
const compileResult = spawnSync('typst', [
|
||||||
'compile',
|
'compile',
|
||||||
'--root',
|
'--root',
|
||||||
'.',
|
'.',
|
||||||
relative(docsDir, typstFile),
|
relative(docsDir, sourceFile),
|
||||||
relative(docsDir, pdfFile),
|
relative(docsDir, pdfFile),
|
||||||
], {
|
], {
|
||||||
cwd: docsDir,
|
cwd: docsDir,
|
||||||
@@ -320,5 +32,4 @@ if (compileResult.status !== 0) {
|
|||||||
throw new Error('Typst PDF build failed');
|
throw new Error('Typst PDF build failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdout.write(`Generated ${relative(docsDir, typstFile)}\n`);
|
|
||||||
process.stdout.write(`Generated ${relative(docsDir, pdfFile)}\n`);
|
process.stdout.write(`Generated ${relative(docsDir, pdfFile)}\n`);
|
||||||
|
|||||||
2922
docs/tz-fregat.typ
Normal file
2922
docs/tz-fregat.typ
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user