450 lines
17 KiB
Typst
450 lines
17 KiB
Typst
// ==========================================
|
||
// ОСНОВНАЯ ФУНКЦИЯ ШАБЛОНА ОТЧЕТА
|
||
// ==========================================
|
||
#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
|
||
}
|