// ========================================== // ОСНОВНАЯ ФУНКЦИЯ ШАБЛОНА ОТЧЕТА // ========================================== #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-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() } } // --- ВСТАВКА ОСНОВНОГО ТЕКСТА --- // В Typst leading — дополнительный промежуток между строками, а не // коэффициент высоты строки. Для Times New Roman 12 pt значение 0.85em // даёт шаг базовых линий около 18 pt, то есть полуторный интервал. set par(leading: 0.85em, spacing: 0.85em) body } // ========================================== // СОДЕРЖАНИЕ (вставляется вызывающей стороной — render-report) // ========================================== #let report-toc() = { let service-title = title => { align(center)[ #text(size: 14pt, weight: "bold")[#title] #v(1.2em) ] } service-title([СОДЕРЖАНИЕ]) show outline.entry.where(level: 1): it => { v(12pt, weak: true) if it.element.numbering == none { text(weight: "bold")[#it] } else { text(weight: "regular")[#it] } } show outline.entry.where(level: 1): it => { if it.element.supplement == [Приложение] { 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) }