Files
2026-10-09 04:24:38 +00:00

450 lines
17 KiB
Typst
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// ОСНОВНАЯ ФУНКЦИЯ ШАБЛОНА ОТЧЕТА
// ==========================================
#import "presentation/lists.typ": list-numbering
#let report(
// ВАЖНО: позиционный аргумент (body) всегда должен идти ПЕРВЫМ
body,
company_info: "ООО «Скиентия» ИНН 6686148633\nул. Шейнкмана, стр. 9, офис 65\nг. Екатеринбург, 620014, Россия\n+7 (922) 203-24-60 ☏\ninfo@scientia.ru 🖂",
udk: "622.023; 622.271",
director_company: "Директор ООО «Скиентия»",
director_name: "Мусихин А.С.",
director_date: "«23» августа 2025 г.",
sign_image: none,
stamp_image: none,
logo_image: path("../companies/logo.svg"),
is_research: true,
is_intermediate: false,
stage_num: none,
vol_num: none,
title: "МЕСТО ДЛЯ ВВОДА ТЕКСТА",
theme: none,
contract_num: "МЕСТО ДЛЯ ВВОДА ТЕКСТА",
contract_date: "«22» августа 2025",
city: "Екатеринбург",
year: datetime.today().year(),
executors: (),
figure-before: 0.75em,
figure-after: 0.75em,
table-before: 0.75em,
table-after: 0.75em,
figure-caption-before: 0pt,
figure-caption-after: 0pt,
table-caption-before: 0pt,
table-caption-after: 0pt,
show_title_page: true,
show_executors: true,
show_outline: true,
report_mode: "default", // default | draft
watermark: none,
) = {
let resolve-asset-path = resource => {
if type(resource) == str and resource.starts-with("companies/") {
path("../" + resource)
} else {
resource
}
}
let mode = if report_mode == "draft" { "draft" } else { "default" }
let with-signs-and-stamps = mode == "default"
let has-watermark = watermark != none
let watermark-mark = (dx, dy) => place(center + horizon, dx: dx, dy: dy)[
#rotate(35deg)[
#text(
font: "Arial",
size: 40pt,
weight: "bold",
fill: rgb("e8c9c9").transparentize(65%),
)[#watermark]
]
]
let page-foreground = if has-watermark {
[
#watermark-mark(-4.8cm, -8.5cm)
#watermark-mark(0cm, 0cm)
#watermark-mark(4.8cm, 8.5cm)
]
} else {
none
}
let service-title = title => {
v(1.1em, weak: true)
block(sticky: true, width: 100%, {
set par(first-line-indent: 0pt, leading: 0.65em)
set text(weight: "bold", size: 12pt, hyphenate: false)
align(center)[#upper(title)]
})
}
let heading-pre-gap = 1.5em
let heading-to-text-gap = 1.2em
// --- БАЗОВЫЕ НАСТРОЙКИ ---
set document(title: title, author: director_company)
set text(font: "Times New Roman", size: 12pt, lang: "ru")
set page(paper: "a4", margin: (left: 3cm, right: 1.5cm, top: 2cm, bottom: 2cm), foreground: page-foreground)
set par(
justify: true,
first-line-indent: (amount: 1.25cm, all: true),
leading: 0.85em,
// Typst размещает соседние абзацы по отдельному параметру spacing.
// Для этого шрифта 0.85em сохраняет тот же шаг около 18 pt.
spacing: 0.85em,
)
// Табличный текст никогда не наследует красную строку основного текста.
// Правило распространяется и на обычный table, и на corp-table.
show table.cell: set par(first-line-indent: 0pt)
// --- НАСТРОЙКИ СПИСКОВ И ОТСТУПОВ ---
// Слабые отступы схлопываются на границе страницы и между соседними
// структурными блоками. Поэтому здесь нет состояния, которое могло бы
// протечь через таблицу, библиографию или принудительный перенос страницы.
show figure: it => {
let block-before = if it.kind == table { table-before } else { figure-before }
let block-after = if it.kind == table { table-after } else { figure-after }
v(block-before, weak: true)
it
v(block-after, weak: true)
}
show math.equation.where(block: true): it => {
it
}
// Базовые отступы для списков
// Внутри многострочного пункта строки компактнее обычного абзаца, а
// соседние пункты отделены друг от друга заметным интервалом.
set enum(indent: 0.55cm, body-indent: 0.3em, spacing: 0.85em)
set list(indent: 0.7cm, body-indent: 0.5em, spacing: 0.85em, marker: ([--], [--], [--]))
show enum: it => block(above: 0.85em, below: 0.85em, {
set par(leading: 0.55em, spacing: 0pt)
it
})
show list: it => block(above: 0.85em, below: 0.85em, {
set par(leading: 0.55em, spacing: 0pt)
it
})
// ГОСТ-формат по умолчанию использует тот же движок, что и numbered-list.
set enum(full: true, numbering: list-numbering(scheme: "gost"))
// --- НАСТРОЙКИ ЗАГОЛОВКОВ ---
set heading(numbering: "1.1")
show heading: it => {
v(heading-pre-gap, weak: true)
let w = if it.level <= 3 { "bold" } else { "regular" }
set text(weight: w, size: 12pt, hyphenate: false)
// Блок для того, чтобы локальные настройки абзаца применялись ТОЛЬКО к заголовку
[#block(sticky: true, width: 100%, {
set par(first-line-indent: 0pt, leading: 0.65em)
let alignment = if it.level <= 2 { center } else { left }
align(alignment)[
#if it.level == 1 {
if it.numbering != none {
[#counter(heading).display() #upper(it.body)]
} else {
[#upper(it.body)]
}
} else {
if it.numbering != none {
[#counter(heading).display() #it.body ]
} else {
[#it.body]
}
}
]
})#v(heading-to-text-gap, weak: true)]
}
// Сброс счетчиков рисунков/таблиц/формул при новой главе
show heading.where(level: 1): it => {
counter(figure.where(kind: image)).update(0)
counter(figure.where(kind: table)).update(0)
counter(math.equation).update(0)
it
}
// --- НАСТРОЙКИ РИСУНКОВ И ТАБЛИЦ ---
set figure(
numbering: (..nums) => {
let chap = counter(heading).get().first()
if chap > 0 { str(chap) + "." + str(nums.pos().first()) } else { str(nums.pos().first()) }
},
// Задаем полные слова для стандартных ссылок (вместо Рис. / Табл.)
supplement: body => {
if type(body) == content and body.func() == table [Таблица] else [Рисунок]
},
)
// Формат подписей рисунков/таблиц (через тире, как в ГОСТ)
show figure.where(kind: image): set figure.caption(position: bottom)
show figure.where(kind: image): set block(breakable: false)
show figure.where(kind: image): set align(center)
show figure.caption: it => block(
sticky: true,
width: if it.kind == table { 100% } else { auto },
)[
#set text(hyphenate: false)
#set par(
leading: 0.65em,
// Подписи рисунков и таблиц являются отдельными служебными строками,
// поэтому красная строка в них не применяется.
first-line-indent: 0pt,
)
#let cap-before = if it.kind == table { table-caption-before } else { figure-caption-before }
#let cap-after = if it.kind == table { table-caption-after } else { figure-caption-after }
#let prefix = if it.kind == table [Таблица] else [Рисунок]
#let num = it.counter.display(it.numbering)
#v(cap-before)
#align(if it.kind == table { left } else { center })[
#prefix #num -- #it.body
]
#v(cap-after)
]
show figure.where(kind: table): set figure.caption(position: top)
show figure.where(kind: table): set block(breakable: true)
// Формулы: центр по строке, номер справа в формате (глава.номер)
set math.equation(
numbering: (..nums) => {
let chap = counter(heading).get().first()
let num = nums.pos().first()
if chap > 0 { "(" + str(chap) + "." + str(num) + ")" } else { "(" + str(num) + ")" }
},
number-align: end + horizon,
supplement: none,
)
if show_title_page {
// ==========================================
// 1. ТИТУЛЬНЫЙ ЛИСТ
// ==========================================
set page(numbering: none, footer: none, foreground: page-foreground)
// Верхний оранжевый блок с логотипом (прижат к левому краю бумаги)
place(top + left, dx: -2cm, dy: -2cm)[
#rect(fill: rgb("fbb20d"), width: 6.5cm, height: 5.5cm, radius: (bottom: 15pt))[
#place(center + horizon)[
#if logo_image != none {
image(resolve-asset-path(logo_image), width: 4cm)
}
]
]
]
// Нижний оранжевый блок
place(bottom + left, dx: -2cm, dy: 2cm)[
#rect(fill: rgb("fbb20d"), width: 6.5cm, height: 2.5cm, radius: (top: 15pt))
]
// Реквизиты сверху справа
align(right)[
#text(font: "Arial", size: 8pt, fill: luma(100), align(right)[#company_info])
]
v(3cm)
// Блок УДК и Утверждаю
grid(
columns: (1fr, 1fr),
align(left)[
#if udk != none [ УДК: #udk ]
],
align(right)[
#box(width: 8cm)[
#set align(right)
УТВЕРЖДАЮ: \
#director_company \
#v(0.2cm)
#grid(
columns: (4.2cm, 1fr),
column-gutter: 0.2cm,
align: (center + horizon, right + horizon),
box(width: 4.2cm, height: 3.5cm)[
#if with-signs-and-stamps and stamp_image != none {
place(center, dx: -0.4cm)[
#rotate(8deg)[
#image(
resolve-asset-path(stamp_image),
width: 3.5cm,
height: 3.5cm,
fit: "contain",
)
]
]
}
#if with-signs-and-stamps and sign_image != none {
place(center, dx: 0.8cm)[
#rotate(-4deg)[
#image(
resolve-asset-path(sign_image),
width: 3cm,
height: 2.5cm,
fit: "contain",
)
]
]
}
],
align(right)[
#director_name \
#director_date
],
)
]
],
)
v(1.7cm)
// Название
align(center)[
ОТЧЕТ \
#if is_research [
О НАУЧНО-ИССЛЕДОВАТЕЛЬСКОЙ РАБОТЕ
] else [
О РАБОТЕ
]
#v(0cm)
#text(weight: "bold", size: 14pt)[«#upper(title)»] \
#v(0cm)
#if theme != none {
[по теме: \ #theme \ ]
}
#if is_intermediate {
[(промежуточный)]
}
#if stage_num != none or vol_num != none {
v(0.001cm)
}
#if stage_num != none {
[Этап #stage_num \ ]
}
#if vol_num != none {
[Том #vol_num \ ]
}
#if mode == "draft" and has-watermark {
[#text(fill: rgb("cc0000"), weight: "bold")[DRAFT] \ ]
}
]
v(1fr) // Пружина, толкает текст вниз
// Реквизиты договора не должны оставлять служебные подписи при пустых данных.
let has-contract-number = contract_num != none and (
type(contract_num) != str or contract_num.trim() != ""
)
let has-contract-date = contract_date != none and (
type(contract_date) != str or contract_date.trim() != ""
)
if has-contract-number or has-contract-date {
align(right)[
#if has-contract-number [Договор № #contract_num]
#if has-contract-number and has-contract-date { linebreak() }
#if has-contract-date [От #contract_date г.]
]
}
v(1cm)
align(center)[#city #year]
v(1cm)
pagebreak()
}
// ==========================================
// 2. СПИСОК ИСПОЛНИТЕЛЕЙ
// ==========================================
// Встроенное page(numbering: ...) может наследовать контекст выравнивания
// на промежуточном фрагменте разрываемой строки таблицы. Независимый footer
// гарантирует одно и то же положение номера на каждой странице.
set page(
numbering: none,
footer: context {
set text(font: "Times New Roman", size: 12pt)
block(width: 100%)[#align(center)[#counter(page).display("1")]]
},
footer-descent: 30%,
foreground: page-foreground,
)
// Титульный лист не входит в видимую нумерацию. Если титула нет,
// основной текст или первая выбранная служебная страница получает номер 1.
counter(page).update(1)
if show_executors and executors.len() > 0 {
service-title([СПИСОК ИСПОЛНИТЕЛЕЙ])
v(1cm)
table(
columns: (1fr, 3.4cm, auto),
stroke: none,
row-gutter: 2.3em,
align: (left + bottom, center + bottom, left + bottom),
..executors
.map(e => (
e.at(0),
[
#if e.len() > 2 and e.at(2) != none {
let offset = if e.len() > 3 { e.at(3) } else { -0.2cm }
// Картинка располагается относительно линии (bottom ячейки)
if with-signs-and-stamps{place(bottom + center, dy: offset)[
#image(resolve-asset-path(e.at(2)), width: 90%)
]}
}
// Линия является единственным элементом, занимающим место,
// поэтому она всегда строго на уровне нижней границы текста других колонок!
#line(length: 100%, stroke: 0.5pt)
// Текст "Подпись" подвешивается снизу без влияния на высоту строки
#place(top + center, dy: 0.4cm)[
#text(size: 8pt)[Подпись]
]
],
e.at(1),
))
.flatten()
)
pagebreak()
}
// ==========================================
// 3. СОДЕРЖАНИЕ
// ==========================================
if show_outline {
service-title([СОДЕРЖАНИЕ])
// Настройка внешнего вида содержания под стандарт
show outline.entry.where(level: 1): it => {
v(12pt, weak: true)
text(weight: "regular")[#it]
}
// Переопределяем отображение элементов содержания для Приложений (где есть supplement "Приложение")
show outline.entry.where(level: 1): it => {
if it.element.supplement == [Приложение] {
// Поскольку нумерация - функция, мы передаем ее в платформенную функцию numbering
let app_num = numbering(it.element.numbering, ..counter(heading).at(it.element.location()))
// Отменяем глобальный абзацный отступ, чтобы Приложение Б, В не уезжали вправо, и делаем кликабельным
set par(first-line-indent: 0pt)
link(it.element.location())[
#text(weight: "regular")[Приложение #app_num. #it.element.body]
#box(width: 1fr, repeat[.])
#it.page()
]
} else {
text(weight: "regular")[#it]
}
}
outline(title: none, depth: 3, indent: 2em)
pagebreak()
}
// --- ВСТАВКА ОСНОВНОГО ТЕКСТА ---
// В Typst leading — дополнительный промежуток между строками, а не
// коэффициент высоты строки. Для Times New Roman 12 pt значение 0.85em
// даёт шаг базовых линий около 18 pt, то есть полуторный интервал.
set par(leading: 0.85em, spacing: 0.85em)
body
}