| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- // ==========================================
- // ОСНОВНАЯ ФУНКЦИЯ ШАБЛОНА ОТЧЕТА
- // ==========================================
- #import "presentation/lists.typ": list-numbering
- #let report(
- // ВАЖНО: позиционный аргумент (body) всегда должен идти ПЕРВЫМ
- body,
- company_info: "",
- udk: "622.023; 622.271",
- director_company: "",
- director_name: "",
- director_date: "",
- 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,
- report_mode: "default", // default | draft | paper
- 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" or report_mode == "paper" { report_mode } else { "default" }
- let with-title-page = mode != "paper"
- // Личный проект: содержание выводится и без фирменного титульного листа.
- let with-toc = true
- 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
- }
- // --- НАСТРОЙКИ РИСУНКОВ И ТАБЛИЦ ---
- // Обычные таблицы по ГОСТ: выравнивание содержимого по правому краю с
- // вертикальным центрированием, поля ячеек. corp-table передаёт align и
- // inset явно и этих значений не наследует.
- set table(inset: (x: 3mm, y: 2.2mm), align: right + horizon)
- // Заголовочные строки (table.header) обычных таблиц — полужирным.
- // Ячейки не знают о принадлежности шапке, поэтому жирнится первая строка
- // (y: 0) таблиц внутри figure; вторые строки table.header в главах
- // помечаются *...* вручную. Вне figure (таблица исполнителей) не действует.
- show figure.where(kind: table): it => {
- show table.cell.where(y: 0): set text(weight: "bold")
- 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 with-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,
- )
- if with-title-page {
- counter(page).update(1)
- if 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 with-toc {
- 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
- }
|