Initial commit

This commit is contained in:
tkachenko committed 2026-10-09 01:45:17 +00:00
commit 41d230d524
163 files changed
+11500

No files matched your search

+409
View File
@@ -0,0 +1,409 @@
// ==========================================
// PRESENTATION: ПЕРЕИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ
// ==========================================
#import "../components.typ": corp_table, formula
#import "foundation.typ": render-media-slot
#import "../shared/numbering.typ": attachment_numbering
#let letter-accent = rgb("fbb20d")
// Большая информационная плашка для замечаний, пояснений и важных оговорок.
#let info-block(
body,
title: [ИНФОРМАЦИЯ],
fill: rgb("fbebec"),
accent: rgb("d73a49"),
text-fill: rgb("#402023"),
radius: 8pt,
inset: 8pt,
) = block(
width: 100%,
breakable: true,
fill: fill,
stroke: (left: 3pt + accent),
radius: radius,
inset: inset,
)[
#set par(first-line-indent: 0pt, justify: false)
#if title != none {
block(below: 0.35em)[
#text(fill: accent, weight: "bold", size: 9pt)[#title]
]
}
#text(fill: text-fill, size: 9pt)[#body]
]
#let footer-icon(name, color) = {
box(width: 20pt, height: 20pt, fill: color, radius: 2pt)[
#align(center + horizon)[
#image(
path("../assets/icons/" + name + ".png"),
width: 15pt,
height: 15pt,
fit: "contain",
)
]
]
}
#let company-logo(company, width: 4cm, height: 2.5cm, policy: "placeholder") = {
render-media-slot(
company.resources.logo,
policy: policy,
width: width,
height: height,
label: "ЛОГОТИП",
)
}
#let signing-media(stamp, signature, policy, show_stamp: true, offset: -1cm) = {
let has-missing-media = signature == none or (show_stamp and stamp == none)
let stamp-resource = if stamp == none and policy == "placeholder" {
path("../assets/placeholders/stamp.svg")
} else {
stamp
}
let signature-resource = if signature == none and policy == "placeholder" {
path("../assets/placeholders/signature.svg")
} else {
signature
}
if policy == "placeholder" and has-missing-media {
if show_stamp {
place(center, dx: -1.6cm, dy: offset)[
#render-media-slot(
stamp-resource,
policy: policy,
width: 2.8cm,
height: 2.8cm,
label: "ПЕЧАТЬ",
)
]
}
place(center, dx: if show_stamp { 1.6cm } else { 0cm }, dy: offset)[
#render-media-slot(
signature-resource,
policy: policy,
width: 2.8cm,
height: 1.4cm,
label: "ПОДПИСЬ",
)
]
} else {
if show_stamp {
place(center, dx: -0.5cm, dy: offset)[
#render-media-slot(
stamp-resource,
policy: policy,
width: 3.5cm,
height: 3.5cm,
label: "МЕСТО ПЕЧАТИ",
)
]
}
place(center, dx: if show_stamp { 1.5cm } else { 0cm }, dy: offset)[
#render-media-slot(
signature-resource,
policy: policy,
width: 3.5cm,
height: 2.5cm,
label: "МЕСТО ПОДПИСИ",
)
]
}
}
#let signature-block(
signer,
company: none,
media_policy: "placeholder",
show_stamp: true,
note: none,
) = {
let signature = signer.at(
"signature",
default: if company == none { none } else { company.resources.signature },
)
let stamp = signer.at(
"stamp",
default: if company == none { none } else { company.resources.stamp },
)
set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
let left-content = [
#signer.title
#if company != none { [\ #company.legal.name] }
]
let right-content = [#signer.name]
let media-content = signing-media(
stamp,
signature,
media_policy,
show_stamp: show_stamp,
)
v(2cm)
layout(size => {
let left-width = measure(left-content).width
let right-width = measure(right-content).width
let roomy = left-width + right-width + 5.5cm <= size.width
if roomy {
grid(
columns: (auto, 1fr, auto),
align: (left + bottom, center + bottom, right + bottom),
left-content,
media-content,
right-content,
)
} else {
grid(
columns: (1fr, 6.5cm, 1fr),
column-gutter: 8pt,
align: (left + bottom, center + bottom, right + bottom),
left-content,
media-content,
right-content,
)
}
})
if note != none {
text(font: "Arial", size: 7.5pt, fill: luma(90))[#note]
}
}
#let approval-block(approval, company, media_policy: "placeholder") = {
let signer = approval.signer
let signature = signer.at("signature", default: company.resources.signature)
let signature-resource = if signature == none and media_policy == "placeholder" {
path("../assets/placeholders/signature.svg")
} else {
signature
}
set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
box(width: 8cm)[
#align(right)[
#text(weight: "bold")[#approval.label] \
#signer.title \
#company.legal.name \
#v(0.2cm)
#render-media-slot(
signature-resource,
policy: media_policy,
width: 3cm,
height: 1.2cm,
label: "ПОДПИСЬ",
) \
#signer.name \
#if approval.date != none { approval.date }
]
]
}
#let company-footer(company) = {
let contacts = company.contacts
let banking = company.banking
let contact-lines = ()
if contacts.phone != none { contact-lines.push(contacts.phone) }
if contacts.email != none { contact-lines.push(contacts.email) }
if contacts.website != none { contact-lines.push(contacts.website) }
let id-lines = ()
for (key, label) in (
("inn", "ИНН"),
("kpp", "КПП"),
("ogrn", "ОГРН"),
("bin", "БИН"),
("kbe", "КБЕ"),
) {
let value = company.legal.at(key, default: none)
if value != none { id-lines.push([#label #value]) }
}
let banking-lines = ()
if banking.bank != none { banking-lines.push([Банк: #banking.bank]) }
if banking.at("correspondent-account", default: none) != none {
banking-lines.push([Кор. счёт: #banking.at("correspondent-account")])
}
if banking.bik != none { banking-lines.push([БИК: #banking.bik]) }
if contacts.at("address-en", default: none) != none {
banking-lines.push(contacts.at("address-en"))
}
set text(font: "Arial", size: 7.5pt, fill: luma(85))
set par(first-line-indent: 0pt, justify: false, leading: 0.2em)
grid(
columns: (1fr, 1.2fr),
column-gutter: 15pt,
[
#grid(
columns: (20pt, 1fr),
rows: (auto, auto),
column-gutter: 8pt,
row-gutter: 8pt,
if contact-lines.len() == 0 { none } else { footer-icon("email", company.brand.color) },
contact-lines.join([\ ]),
if contacts.address == none { none } else { footer-icon("location", company.brand.color) },
if contacts.address == none { none } else { contacts.address },
)
],
grid(
columns: (1fr, 20pt),
rows: (auto, auto),
column-gutter: 8pt,
row-gutter: 8pt,
align: (right, center + horizon),
align(right, id-lines.join([\ ])),
if id-lines.len() == 0 { none } else { footer-icon("card", company.brand.color) },
align(right, banking-lines.join([\ ])),
if banking-lines.len() == 0 { none } else { footer-icon("bank", company.brand.color) },
),
)
}
#let letter-header(
company,
recipient,
date: none,
reference: none,
title: none,
media_policy: "placeholder",
) = {
set par(first-line-indent: 0pt, justify: false, leading: 0.65em)
place(top + left, dx: 10cm, dy: -1.5cm)[
#rect(
width: 6.5cm,
height: 2cm,
fill: letter-accent,
radius: (bottom: 2pt),
)
]
v(30pt)
box(width: 4cm, height: 2.5cm)[
#company-logo(company, width: 4cm, height: 2.5cm, policy: media_policy)
]
v(-50pt)
v(2.5cm)
grid(
columns: (1.4fr, 1fr),
[
#if reference != none { [Исх. № #reference] }
#if date != none { [ от #date] }
],
[
#if recipient.company != none { [#recipient.company\ ] }
#if recipient.title != none { [#recipient.title\ ] }
#if recipient.name != none { recipient.name }
#if recipient.address != none { [\ #recipient.address] }
],
)
v(0.7cm)
if title != none {
align(center)[#text(weight: "bold")[#upper(title)]]
v(0.2cm)
}
}
#let attachment-list(attachments) = {
let items = if type(attachments) == dictionary { attachments.items } else { attachments }
if items.len() > 0 {
set par(first-line-indent: 0pt, justify: false)
for (index, item) in items.enumerate() {
let number = if item.number == auto { index + 1 } else { item.number }
[Приложение #number: #item.title]
if index < items.len() - 1 { linebreak() }
}
}
}
#let requisites-table(parties) = {
let field-labels = (
inn: "ИНН",
kpp: "КПП",
ogrn: "ОГРН",
bin: "БИН",
kbe: "КБе",
address: "Юридический адрес",
legal-address: "Юридический адрес",
phone: "Телефон",
email: "E-mail",
website: "Сайт",
bank: "Банк",
account: "Расчётный счёт",
settlement-account: "Расчётный счёт",
correspondent-account: "Корреспондентский счёт",
ks: "Корреспондентский счёт",
bik: "БИК",
iban: "IBAN",
)
let hidden-fields = (
"name",
"short-name",
"jurisdiction",
"summary",
"address-en",
"city",
)
let render-fields(fields) = {
for (key, value) in fields {
if value != none and key not in hidden-fields {
let label = field-labels.at(key, default: key)
[#label: #value\ ]
}
}
}
let columns = range(parties.len()).map(_ => 1fr)
table(
columns: columns,
stroke: 0.5pt,
inset: 6pt,
..parties.map(item => [
#set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
#text(weight: "bold")[#item.role] \
#item.name \
#render-fields(item.legal)
#render-fields(item.contacts)
#render-fields(item.banking)
]),
)
}
#let multi-party-signing(parties, media_policy: "reserve-space") = {
let columns = range(parties.len()).map(_ => 1fr)
grid(
columns: columns,
column-gutter: 16pt,
..parties.map(item => {
let person = item.representative
[
#set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
#if person != none {
grid(
columns: (1fr,),
rows: (auto, 0.35cm, 3.5cm, 0.25cm, auto),
[#text(weight: "bold")[#item.role] \ #person.title],
[],
block(width: 100%, height: 3.5cm)[
#signing-media(
person.stamp,
person.signature,
media_policy,
show_stamp: true,
offset: 0cm,
)
],
[],
[#person.name],
)
} else {
text(weight: "bold")[#item.role]
}
]
}),
)
}
#let appendix-heading(item, display-number) = {
set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
align(right)[Приложение #display-number]
v(0.2cm)
align(center)[
#text(weight: "bold")[#upper(item.title)]
#if item.subtitle != none { [\ #text(weight: "bold")[#item.subtitle]] }
]
}
+119
View File
@@ -0,0 +1,119 @@
// ==========================================
// PRESENTATION: ОБЩАЯ ОСНОВА ВЁРСТКИ
// ==========================================
#let design-tokens(company, overrides: (:)) = {
let defaults = (
brand-color: company.brand.at("color", default: rgb("e39f49")),
text-font: "Times New Roman",
sans-font: "Arial",
text-size: 12pt,
small-size: 8pt,
line-height: 1.5em,
compact-line-height: 0.65em,
paper: "a4",
page-margin: (left: 3cm, right: 1.5cm, top: 2cm, bottom: 2cm),
)
let result = defaults
for (key, value) in overrides {
if key not in defaults {
panic("Scientia / design-tokens: неизвестный token " + repr(key))
}
result.insert(key, value)
}
result
}
#let watermark-layer(value, font: "Arial") = {
if value == none {
none
} else {
[
#place(center + horizon, dx: -4.8cm, dy: -8.5cm)[
#rotate(35deg)[#text(font: font, size: 40pt, weight: "bold", fill: rgb("e8c9c9").transparentize(65%))[#value]]
]
#place(center + horizon)[
#rotate(35deg)[#text(font: font, size: 40pt, weight: "bold", fill: rgb("e8c9c9").transparentize(65%))[#value]]
]
#place(center + horizon, dx: 4.8cm, dy: 8.5cm)[
#rotate(35deg)[#text(font: font, size: 40pt, weight: "bold", fill: rgb("e8c9c9").transparentize(65%))[#value]]
]
]
}
}
#let render-media-slot(
resource,
policy: "placeholder",
width: 4cm,
height: 2cm,
label: "РЕСУРС НЕ ЗАДАН",
fit: "contain",
) = {
if policy not in ("hide", "placeholder", "reserve-space") {
panic("Scientia / media-slot: неизвестная политика " + repr(policy))
}
if resource == none {
if policy == "hide" {
none
} else if policy == "reserve-space" {
box(width: width, height: height)
} else {
box(
width: width,
height: height,
stroke: 0.6pt + luma(150),
fill: luma(245),
inset: 4pt,
align(center + horizon)[
#text(font: "Arial", size: 7pt, fill: luma(110), weight: "bold")[#label]
],
)
}
} else if type(resource) == path {
image(resource, width: width, height: height, fit: fit)
} else if type(resource) == content {
box(width: width, height: height, align(center + horizon, resource))
} else {
panic("Scientia / media-slot: ожидались none, path или content")
}
}
#let apply-foundation(
body,
ctx: none,
tokens: (:),
page-options: (:),
text-options: (:),
par-options: (:),
) = {
if ctx == none {
panic("Scientia / foundation: document context обязателен")
}
let tokens = design-tokens(ctx.company, overrides: tokens)
let page-defaults = (
paper: tokens.paper,
margin: tokens.at("page-margin"),
foreground: watermark-layer(ctx.options.watermark, font: tokens.at("sans-font")),
)
let resolved-page = page-defaults
for (key, value) in page-options { resolved-page.insert(key, value) }
set page(..resolved-page)
set text(
font: tokens.at("text-font"),
size: tokens.at("text-size"),
lang: "ru",
..text-options,
)
set par(
justify: true,
first-line-indent: (amount: 1.25cm, all: true),
leading: tokens.at("line-height"),
..par-options,
)
show table.cell: set par(first-line-indent: 0pt)
show figure.caption.where(kind: table): set par(first-line-indent: 0pt)
body
}
+349
View File
@@ -0,0 +1,349 @@
// ==========================================
// PRESENTATION: НАСТРАИВАЕМЫЕ СПИСКИ
// ==========================================
// Кириллическая последовательность по ГОСТ: буквы, которые легко спутать
// с цифрами или другими обозначениями, намеренно пропущены.
#let cyrillic-upper = (
"А", "Б", "В", "Г", "Д", "Е", "Ж", "И", "К", "Л", "М", "Н",
"П", "Р", "С", "Т", "У", "Ф", "Х", "Ц", "Ш", "Щ", "Э", "Ю", "Я",
)
#let cyrillic-lower = cyrillic-upper.map(lower)
#let alphabetic-number(value, alphabet) = {
if type(value) != int or value < 1 {
str(value)
} else {
let current = value
let result = ""
while current > 0 {
current -= 1
result = alphabet.at(calc.rem(current, alphabet.len())) + result
current = calc.floor(current / alphabet.len())
}
result
}
}
#let padded-number(value, width) = {
let raw = str(value)
"0" * calc.max(width - raw.len(), 0) + raw
}
// Расширенное описание одного уровня. В простых случаях достаточно строки
// в levels: "1", "I", "i", "A", "a", "01", "А", "а" или маркера.
#let list-level(style, prefix: [], suffix: auto, width: auto) = (
kind: "list-level",
style: style,
prefix: prefix,
suffix: suffix,
width: width,
)
#let list-scheme(
levels: ("1", "а", "1"),
full: false,
separators: [],
suffixes: (".", ")", ")"),
) = {
if type(levels) != array or levels.len() == 0 {
panic("Scientia / list-scheme.levels: нужен непустой array")
}
if type(full) != bool {
panic("Scientia / list-scheme.full: ожидался bool")
}
(
kind: "list-scheme",
levels: levels,
full: full,
separators: separators,
suffixes: suffixes,
)
}
#let list-schemes = (
// 1. -> а) -> 1)
gost: list-scheme(),
// 1. -> 1.1. -> 1.1.1.
decimal: list-scheme(
levels: ("1",),
full: true,
separators: ".",
suffixes: ".",
),
// 1 -> 1.1 -> 1.1.1
decimal-plain: list-scheme(
levels: ("1",),
full: true,
separators: ".",
suffixes: [],
),
// 1. -> а. -> ‣
local-mixed: list-scheme(
levels: ("1", "а", "‣"),
suffixes: (".", ".", []),
),
// 1. -> 1.а. -> 1.а.‣
full-mixed: list-scheme(
levels: ("1", "а", "‣"),
full: true,
separators: ".",
suffixes: (".", ".", []),
),
// A) -> A)1.
legal: list-scheme(
levels: ("A", "1"),
full: true,
separators: ")",
suffixes: (")", "."),
),
bullets: list-scheme(
levels: ("•", "∙", "‣", "⁃", "◦"),
suffixes: [],
),
)
// Короткие варианты геометрии списков. `auto` ничего не переопределяет,
// поэтому список наследует настройки документа или окружающей таблицы.
#let resolve-list-layout(
layout,
kind: "enum",
level-indent: auto,
body-indent: auto,
) = {
let preset = if layout == auto {
(level-indent: auto, body-indent: auto)
} else if layout == "normal" {
if kind == "list" {
(level-indent: 0.7cm, body-indent: 0.5em)
} else {
(level-indent: 0.55cm, body-indent: 0.3em)
}
} else if layout == "compact" {
(level-indent: 0.25cm, body-indent: 0.25em)
} else if layout == "flush" {
(level-indent: 0pt, body-indent: 0.25em)
} else {
panic("Scientia / list-layout: ожидались auto, 'normal', 'compact' или 'flush'")
}
(
level-indent: if level-indent == auto { preset.level-indent } else { level-indent },
body-indent: if body-indent == auto { preset.body-indent } else { body-indent },
)
}
#let sequence-value(values, index, scope) = {
let values = if type(values) == array { values } else { (values,) }
if values.len() == 0 {
panic("Scientia / " + scope + ": последовательность не должна быть пустой")
}
values.at(calc.min(index, values.len() - 1))
}
#let resolve-list-scheme(
scheme,
levels: auto,
full: auto,
separators: auto,
suffixes: auto,
) = {
let base = if type(scheme) == str {
if scheme not in list-schemes {
panic(
"Scientia / numbered-list.scheme: неизвестная схема " + repr(scheme)
+ "; доступны " + list-schemes.keys().map(repr).join(", "),
)
}
list-schemes.at(scheme)
} else if type(scheme) == dictionary and scheme.at("kind", default: none) == "list-scheme" {
scheme
} else {
panic("Scientia / numbered-list.scheme: ожидались имя схемы или list-scheme")
}
list-scheme(
levels: if levels == auto { base.levels } else { levels },
full: if full == auto { base.full } else { full },
separators: if separators == auto { base.separators } else { separators },
suffixes: if suffixes == auto { base.suffixes } else { suffixes },
)
}
#let render-level-core(level, value) = {
let description = if type(level) == dictionary and level.at("kind", default: none) == "list-level" {
level
} else {
list-level(level)
}
let style = description.style
let width = description.width
let core = if type(style) == function {
style(value)
} else if style == "1" {
if width == auto { str(value) } else { padded-number(value, width) }
} else if style == "01" {
padded-number(value, if width == auto { 2 } else { width })
} else if style == "I" or style == "i" or style == "A" or style == "a" {
numbering(style, value)
} else if style == "А" {
alphabetic-number(value, cyrillic-upper)
} else if style == "а" {
alphabetic-number(value, cyrillic-lower)
} else if type(style) == str or type(style) == content {
style
} else {
panic("Scientia / list-level.style: ожидались строка, content или function")
}
[#description.prefix#core]
}
#let level-suffix(config, depth) = {
let level = sequence-value(config.levels, depth, "list-scheme.levels")
if type(level) == dictionary and level.at("kind", default: none) == "list-level" and level.suffix != auto {
level.suffix
} else {
sequence-value(config.suffixes, depth, "list-scheme.suffixes")
}
}
#let list-numbering(
scheme: "gost",
levels: auto,
full: auto,
separators: auto,
suffixes: auto,
) = {
let config = resolve-list-scheme(
scheme,
levels: levels,
full: full,
separators: separators,
suffixes: suffixes,
)
(..numbers) => {
let values = numbers.pos()
let depth = values.len() - 1
let visible = if config.full { range(values.len()) } else { (depth,) }
let parts = ()
for (position, index) in visible.enumerate() {
if position > 0 {
parts.push(sequence-value(config.separators, index - 1, "list-scheme.separators"))
}
let level = sequence-value(config.levels, index, "list-scheme.levels")
parts.push(render-level-core(level, values.at(index)))
}
parts.push(level-suffix(config, depth))
parts.join()
}
}
// Оболочка сохраняет нативный enum: вложенность, переносы страниц,
// многоабзацные пункты и явные начальные номера продолжают работать штатно.
#let numbered-list(
body,
scheme: "gost",
levels: auto,
full: auto,
separators: auto,
suffixes: auto,
layout: auto,
outer-indent: 0pt,
level-indent: auto,
body-indent: auto,
line-leading: 0.55em,
item-spacing: 0.85em,
paragraph-spacing: 0pt,
number-align: end,
) = {
let numbering = list-numbering(
scheme: scheme,
levels: levels,
full: full,
separators: separators,
suffixes: suffixes,
)
let geometry = resolve-list-layout(
layout,
kind: "enum",
level-indent: level-indent,
body-indent: body-indent,
)
let enum-options = (
full: true,
numbering: numbering,
spacing: item-spacing,
number-align: number-align,
)
if geometry.level-indent != auto {
enum-options.insert("indent", geometry.level-indent)
}
if geometry.body-indent != auto {
enum-options.insert("body-indent", geometry.body-indent)
}
block(width: 100%)[
#set enum(..enum-options)
// Важно применять параметры абзаца ко всему enum. Show-правило для
// enum.item повторно контекстуализирует каждый вложенный пункт и в Typst
// 0.15 сбрасывает его локальный счётчик на 1.
#show enum: it => {
set par(
first-line-indent: 0pt,
leading: line-leading,
spacing: paragraph-spacing,
)
it
}
#pad(left: outer-indent)[#body]
]
}
// Симметричная оболочка для маркированных списков. Без параметров она не
// меняет стиль документа; layout и точные отступы действуют только локально.
#let bullet-list(
body,
layout: auto,
marker: auto,
outer-indent: 0pt,
level-indent: auto,
body-indent: auto,
line-leading: auto,
item-spacing: auto,
paragraph-spacing: auto,
) = {
let geometry = resolve-list-layout(
layout,
kind: "list",
level-indent: level-indent,
body-indent: body-indent,
)
let list-options = (:)
if geometry.level-indent != auto {
list-options.insert("indent", geometry.level-indent)
}
if geometry.body-indent != auto {
list-options.insert("body-indent", geometry.body-indent)
}
if marker != auto {
list-options.insert("marker", marker)
}
if item-spacing != auto {
list-options.insert("spacing", item-spacing)
}
let par-options = (first-line-indent: 0pt)
if line-leading != auto {
par-options.insert("leading", line-leading)
}
if paragraph-spacing != auto {
par-options.insert("spacing", paragraph-spacing)
}
block(width: 100%)[
#set list(..list-options)
#show list: it => {
set par(..par-options)
it
}
#pad(left: outer-indent)[#body]
]
}
@@ -0,0 +1,203 @@
// ==========================================
// PROFILE: ТЕХНИКО-КОММЕРЧЕСКОЕ ПРЕДЛОЖЕНИЕ
// ==========================================
#import "../../domain/document.typ": document-profile, fail
#import "../../domain/attachments.typ": attachment-set
#import "../../domain/parties.typ": signer
#import "../foundation.typ": apply-foundation
#import "../components.typ": attachment-list, appendix-heading, company-footer, letter-header, signature-block
#let commercial-terms(
amount: none,
currency: "RUB",
tax_note: none,
delivery_term: none,
validity: none,
payment_terms: none,
) = {
(
amount: amount,
currency: currency,
tax-note: tax_note,
delivery-term: delivery_term,
validity: validity,
payment-terms: payment_terms,
)
}
#let normalize-offer(metadata) = {
if type(metadata.attachments) == array {
metadata.insert("attachments", attachment-set(items: metadata.attachments))
}
metadata
}
#let validate-offer(metadata) = {
if metadata.recipient == none or type(metadata.recipient) != dictionary or metadata.recipient.at("kind", default: none) != "recipient" {
fail("commercial-offer.recipient", "ожидался recipient")
}
if type(metadata.subject) != str or metadata.subject.trim() == "" {
fail("commercial-offer.subject", "предмет предложения обязателен")
}
if type(metadata.terms) != dictionary {
fail("commercial-offer.terms", "ожидался commercial-terms")
}
if metadata.terms.amount == none {
fail("commercial-offer.terms.amount", "стоимость обязательна")
}
if type(metadata.attachments) != dictionary or metadata.attachments.at("kind", default: none) != "attachment-set" {
fail("commercial-offer.attachments", "ожидался attachment-set")
}
metadata
}
#let effective-offer-signer(data, company, include-media: true) = {
let source = if data.signer == none {
signer(
company.director.name,
company.director.title,
signature: company.resources.signature,
stamp: company.resources.stamp,
)
} else {
data.signer
}
signer(
source.name,
source.title,
basis: source.basis,
signature: if include-media { source.signature } else { none },
stamp: if include-media { source.stamp } else { none },
)
}
#let offer-summary(data) = {
let terms = data.terms
set par(first-line-indent: 0pt, justify: false, leading: 0.7em)
table(
columns: (4cm, 1fr),
stroke: 0.5pt + luma(170),
inset: 6pt,
fill: (x, _) => if x == 0 { luma(245) } else { none },
[*Предмет*], [#data.subject],
[*Стоимость*], [#terms.amount #terms.currency],
..if terms.at("tax-note") != none { ([*Налоги*], [#terms.at("tax-note")]) } else { () },
..if terms.at("delivery-term") != none { ([*Срок выполнения*], [#terms.at("delivery-term")]) } else { () },
..if terms.validity != none { ([*Срок действия*], [#terms.validity]) } else { () },
..if terms.at("payment-terms") != none { ([*Порядок оплаты*], [#terms.at("payment-terms")]) } else { () },
)
}
#let render-offer-attachments(attachments, appendix-state) = {
for (index, item) in attachments.items.enumerate() {
pagebreak()
appendix-state.update(true)
let number = if item.number == auto { index + attachments.start } else { item.number }
appendix-heading(item, str(number))
v(0.5cm)
if type(item.body) == function { (item.body)() } else { item.body }
}
}
#let render-commercial-offer(body, ctx) = {
let company = ctx.company
let data = ctx.metadata
let include-media = ctx.options.mode == "final"
let person = effective-offer-signer(data, company, include-media: include-media)
let media-policy = if include-media { ctx.options.at("media-policy") } else { "reserve-space" }
let appendix-state = state("scientia-offer-in-appendix", false)
let footer = context {
if not appendix-state.get() { company-footer(company) }
}
let composed = [
#letter-header(
company,
data.recipient,
date: data.date,
reference: data.reference,
title: data.title,
media_policy: ctx.options.at("media-policy"),
)
#v(0.6cm)
#offer-summary(data)
#v(0.7cm)
#body
#if data.attachments.items.len() > 0 {
v(0.8cm)
attachment-list(data.attachments)
}
#signature-block(
person,
company: company,
media_policy: media-policy,
show_stamp: data.at("show-stamp"),
note: data.note,
)
#if data.at("render-attachments") and data.attachments.items.len() > 0 {
render-offer-attachments(data.attachments, appendix-state)
}
]
apply-foundation(
composed,
ctx: ctx,
tokens: (
text-font: "Arial",
text-size: 10pt,
line-height: 0.65em,
page-margin: (top: 1.5cm, bottom: 4cm, left: 1.5cm, right: 1.5cm),
),
page-options: (
footer: footer,
footer-descent: 20%,
),
)
}
#let commercial-offer-profile(
recipient: none,
subject: "",
amount: none,
currency: "RUB",
tax_note: none,
delivery_term: none,
validity: none,
payment_terms: none,
date: none,
reference: none,
title: "Технико-коммерческое предложение",
signer: none,
note: none,
show_stamp: true,
attachments: attachment-set(),
render_attachments: true,
) = {
document-profile(
"commercial-offer",
render-commercial-offer,
metadata: (
recipient: recipient,
subject: subject,
terms: commercial-terms(
amount: amount,
currency: currency,
tax_note: tax_note,
delivery_term: delivery_term,
validity: validity,
payment_terms: payment_terms,
),
date: date,
reference: reference,
title: title,
signer: signer,
note: note,
show-stamp: show_stamp,
parties: (),
attachments: attachments,
bibliographies: (),
render-attachments: render_attachments,
),
normalize: normalize-offer,
validate: validate-offer,
)
}
@@ -0,0 +1,205 @@
// ==========================================
// PROFILE: ДОГОВОР
// ==========================================
#import "../../domain/document.typ": document-profile, fail
#import "../../domain/attachments.typ": attachment-set
#import "../../domain/parties.typ": validate-parties, signer
#import "../../shared/numbering.typ": attachment_numbering
#import "../foundation.typ": apply-foundation
#import "../components.typ": appendix-heading, multi-party-signing, requisites-table
#let contract-section(
id,
title,
body,
number: auto,
level: 1,
) = {
if type(id) != str or id.trim() == "" {
fail("contract-section.id", "нужна непустая строка")
}
if type(title) not in (str, content) {
fail("contract-section.title", "ожидались str или content")
}
if type(body) not in (content, function) {
fail("contract-section.body", "ожидались content или function")
}
if type(level) != int or level < 1 {
fail("contract-section.level", "ожидалось положительное целое число")
}
(
kind: "contract-section",
id: id,
title: title,
body: body,
number: number,
level: level,
)
}
#let validate-sections(sections) = {
if type(sections) != array {
fail("contract.sections", "ожидался array")
}
let seen = ()
for section in sections {
if type(section) != dictionary or section.at("kind", default: none) != "contract-section" {
fail("contract.sections", "каждый элемент должен быть contract-section")
}
if section.id in seen { fail("contract.sections", "повторяющийся id " + repr(section.id)) }
seen.push(section.id)
}
sections
}
#let normalize-contract(metadata) = {
if type(metadata.attachments) == array {
metadata.insert("attachments", attachment-set(items: metadata.attachments))
}
metadata
}
#let validate-contract(metadata) = {
if type(metadata.title) not in (str, content) {
fail("contract.title", "ожидались str или content")
}
if metadata.number == none {
fail("contract.number", "номер договора обязателен")
}
let _ = validate-parties(metadata.parties, minimum: 2)
let _ = validate-sections(metadata.sections)
if type(metadata.attachments) != dictionary or metadata.attachments.at("kind", default: none) != "attachment-set" {
fail("contract.attachments", "ожидался attachment-set")
}
metadata
}
#let contract-preamble(data) = {
if data.preamble == auto {
[Стороны, указанные в разделе «Реквизиты и подписи сторон», заключили настоящий договор о нижеследующем.]
} else {
data.preamble
}
}
#let render-contract-sections(sections) = {
for (index, section) in sections.enumerate() {
let number = if section.number == auto { index + 1 } else { section.number }
heading(level: section.level, numbering: none)[#number. #upper(section.title)]
if type(section.body) == function { (section.body)() } else { section.body }
}
}
#let signing-parties(parties, include-media: true) = {
parties.map(item => {
if item.representative == none or include-media {
item
} else {
let person = item.representative
let clean-person = signer(
person.name,
person.title,
basis: person.basis,
signature: none,
stamp: none,
)
let clean-item = item
clean-item.insert("representative", clean-person)
clean-item
}
})
}
#let render-contract-attachments(attachments) = {
for (index, item) in attachments.items.enumerate() {
pagebreak()
let source-number = if item.number == auto { index + attachments.start } else { item.number }
let display-number = attachment_numbering(attachments.numbering, source-number)
appendix-heading(item, display-number)
v(0.5cm)
if type(item.body) == function { (item.body)() } else { item.body }
}
}
#let render-contract(body, ctx) = {
let data = ctx.metadata
let include-media = ctx.options.mode == "final"
let media-policy = if include-media { ctx.options.at("media-policy") } else { "reserve-space" }
let signing = signing-parties(data.parties, include-media: include-media)
let composed = [
#set heading(numbering: "1.1")
#show heading: it => {
set text(font: "Times New Roman", size: 11pt, weight: "bold", hyphenate: false)
set par(first-line-indent: 0pt, justify: false)
v(1em, weak: true)
block(sticky: true, width: 100%)[#it]
v(0.5em)
}
#align(center)[
#text(weight: "bold", size: 14pt)[#upper(data.title)] \
№ #data.number
]
#v(0.7cm)
#grid(
columns: (1fr, 1fr),
[#data.place],
align(right)[#data.date],
)
#v(0.8cm)
#contract-preamble(data)
#v(0.6cm)
#if data.sections.len() > 0 { render-contract-sections(data.sections) }
#body
#pagebreak(weak: true)
#heading(numbering: none)[РЕКВИЗИТЫ И ПОДПИСИ СТОРОН]
#requisites-table(data.parties)
#v(1cm)
#multi-party-signing(signing, media_policy: media-policy)
#if data.attachments.items.len() > 0 {
render-contract-attachments(data.attachments)
}
]
apply-foundation(
composed,
ctx: ctx,
tokens: (
text-font: "Times New Roman",
text-size: 11pt,
line-height: 1.15em,
page-margin: (left: 2.5cm, right: 2cm, top: 2cm, bottom: 2cm),
),
)
}
#let contract-profile(
number: none,
date: none,
place: "Екатеринбург",
title: "Договор",
parties: (),
preamble: auto,
sections: (),
attachments: attachment-set(),
) = {
document-profile(
"contract",
render-contract,
metadata: (
number: number,
date: date,
place: place,
title: title,
parties: parties,
preamble: preamble,
sections: sections,
attachments: attachments,
bibliographies: (),
),
normalize: normalize-contract,
validate: validate-contract,
)
}
@@ -0,0 +1,12 @@
// Публичный namespace profiles.
#import "report.typ": report-profile
#import "letter.typ": letter-profile
#import "commercial-offer.typ": commercial-offer-profile, commercial-terms
#import "contract.typ": contract-profile, contract-section
#let report = report-profile
#let letter = letter-profile
#let commercial_offer = commercial-offer-profile
#let contract = contract-profile
#let contract_section = contract-section
#let commercial_terms = commercial-terms
@@ -0,0 +1,147 @@
// ==========================================
// PROFILE: ДЕЛОВОЕ ПИСЬМО
// ==========================================
#import "../../domain/document.typ": document-profile, fail
#import "../../domain/attachments.typ": attachment-set
#import "../../domain/parties.typ": signer
#import "../foundation.typ": apply-foundation
#import "../components.typ": attachment-list, appendix-heading, company-footer, letter-header, signature-block
#let normalize-letter(metadata) = {
if type(metadata.attachments) == array {
metadata.insert("attachments", attachment-set(items: metadata.attachments))
}
metadata
}
#let validate-letter(metadata) = {
if metadata.recipient == none or type(metadata.recipient) != dictionary or metadata.recipient.at("kind", default: none) != "recipient" {
fail("letter.recipient", "ожидался recipient")
}
if metadata.title != none and type(metadata.title) not in (str, content) {
fail("letter.title", "ожидались none, str или content")
}
if metadata.signer != none and (type(metadata.signer) != dictionary or metadata.signer.at("kind", default: none) != "signer") {
fail("letter.signer", "ожидались none или signer")
}
if type(metadata.attachments) != dictionary or metadata.attachments.at("kind", default: none) != "attachment-set" {
fail("letter.attachments", "ожидался attachment-set")
}
metadata
}
#let effective-signer(data, company, include-media: true) = {
let source = if data.signer == none {
signer(
company.director.name,
company.director.title,
signature: company.resources.signature,
stamp: company.resources.stamp,
)
} else {
data.signer
}
signer(
source.name,
source.title,
basis: source.basis,
signature: if include-media { source.signature } else { none },
stamp: if include-media { source.stamp } else { none },
)
}
#let render-letter-attachments(attachments, appendix-state) = {
for (index, item) in attachments.items.enumerate() {
pagebreak()
appendix-state.update(true)
let number = if item.number == auto { index + attachments.start } else { item.number }
appendix-heading(item, str(number))
v(0.5cm)
if type(item.body) == function { (item.body)() } else { item.body }
}
}
#let render-letter(body, ctx) = {
let company = ctx.company
let data = ctx.metadata
let include-media = ctx.options.mode == "final"
let person = effective-signer(data, company, include-media: include-media)
let media-policy = if include-media { ctx.options.at("media-policy") } else { "reserve-space" }
let appendix-state = state("scientia-letter-in-appendix", false)
let footer = context {
if not appendix-state.get() { company-footer(company) }
}
let composed = [
#letter-header(
company,
data.recipient,
date: data.date,
reference: data.reference,
title: data.title,
media_policy: ctx.options.at("media-policy"),
)
#v(0.7cm)
#body
#if data.attachments.items.len() > 0 {
v(0.8cm)
attachment-list(data.attachments)
}
#signature-block(
person,
company: company,
media_policy: media-policy,
show_stamp: data.at("show-stamp"),
note: data.note,
)
#if data.at("render-attachments") and data.attachments.items.len() > 0 {
render-letter-attachments(data.attachments, appendix-state)
}
]
apply-foundation(
composed,
ctx: ctx,
tokens: (
text-font: "Arial",
text-size: 10pt,
line-height: 0.65em,
page-margin: (top: 1.5cm, bottom: 4cm, left: 1.5cm, right: 1.5cm),
),
page-options: (
footer: footer,
footer-descent: 20%,
),
)
}
#let letter-profile(
recipient: none,
date: none,
reference: none,
title: "Деловое письмо",
signer: none,
note: none,
show_stamp: true,
attachments: attachment-set(),
render_attachments: false,
) = {
document-profile(
"letter",
render-letter,
metadata: (
recipient: recipient,
date: date,
reference: reference,
title: title,
signer: signer,
note: note,
show-stamp: show_stamp,
parties: (),
attachments: attachments,
bibliographies: (),
render-attachments: render_attachments,
),
normalize: normalize-letter,
validate: validate-letter,
)
}
@@ -0,0 +1,245 @@
// ==========================================
// PROFILE: НАУЧНО-ТЕХНИЧЕСКИЙ ОТЧЁТ
// ==========================================
#import "../../domain/document.typ": document-profile, fail
#import "../../domain/attachments.typ": attachment-set
#import "../../report.typ": report
#import "../../appendices.typ": make_appendices
#import "../references.typ": render-bibliographies
#let placeholder-path(kind) = {
if kind == "signature" {
path("../../assets/placeholders/signature.svg")
} else if kind == "stamp" {
path("../../assets/placeholders/stamp.svg")
} else {
path("../../assets/placeholders/logo.svg")
}
}
#let report-company-summary(company) = {
let summary = company.contacts.at("summary", default: none)
if summary != none {
summary
} else {
let lines = (company.legal.name,)
let ids = ()
for key in ("inn", "kpp", "ogrn", "bin", "kbe") {
let value = company.legal.at(key, default: none)
if value != none { ids.push(upper(key) + " " + value) }
}
if ids.len() > 0 { lines.push(ids.join(" ")) }
for key in ("address", "phone", "email", "website") {
let value = company.contacts.at(key, default: none)
if value != none { lines.push(value) }
}
lines.join("\n")
}
}
#let resolve-signing-resource(resource, kind, ctx) = {
if ctx.options.mode != "final" {
none
} else if resource != none {
resource
} else if ctx.options.at("media-policy") == "placeholder" {
placeholder-path(kind)
} else {
none
}
}
#let render-report-appendices(sources, numbering, start) = {
if sources.len() > 0 {
make_appendices(
[
#for source in sources {
include(source)
}
],
numbering: numbering,
start: start,
)
}
}
#let normalize-report(metadata) = metadata
#let validate-report(metadata) = {
if type(metadata.title) != str or metadata.title.trim() == "" {
fail("report.title", "название отчёта обязательно")
}
if type(metadata.year) != int {
fail("report.year", "ожидалось целое число")
}
if type(metadata.executors) != array {
fail("report.executors", "ожидался array")
}
for key in ("show-title-page", "show-executors", "show-outline") {
if type(metadata.at(key)) != bool {
fail("report." + key, "ожидался bool")
}
}
if type(metadata.appendices) != array {
fail("report.appendices", "ожидался array путей к файлам")
}
let seen = ()
for source in metadata.appendices {
if type(source) != path {
fail("report.appendices", "каждый элемент должен быть path(...)")
}
if source in seen {
fail("report.appendices", "файл указан повторно: " + repr(source))
}
seen.push(source)
}
if metadata.at("appendix-numbering") not in ("cyrillic", "arabic", "none") {
fail("report.appendix-numbering", "допустимы cyrillic, arabic или none")
}
if type(metadata.at("appendix-start")) != int or metadata.at("appendix-start") < 1 {
fail("report.appendix-start", "нужно положительное целое число")
}
metadata
}
#let render-report(body, ctx) = {
let company = ctx.company
let data = ctx.metadata
let mode = if ctx.options.mode == "final" { "default" } else { "draft" }
let watermark = if ctx.options.mode == "draft" and ctx.options.watermark == none {
"DRAFT"
} else {
ctx.options.watermark
}
let signature = resolve-signing-resource(company.resources.signature, "signature", ctx)
let stamp = resolve-signing-resource(company.resources.stamp, "stamp", ctx)
let logo = if company.resources.logo != none {
company.resources.logo
} else if ctx.options.at("media-policy") == "placeholder" {
placeholder-path("logo")
} else {
none
}
let composed = [
#body
#if ctx.bibliographies.len() > 0 {
pagebreak()
render-bibliographies(ctx.bibliographies)
}
#if data.appendices.len() > 0 {
render-report-appendices(
data.appendices,
data.at("appendix-numbering"),
data.at("appendix-start"),
)
}
]
report(
composed,
company_info: report-company-summary(company),
udk: data.udk,
director_company: company.director.title + " " + company.legal.name,
director_name: company.director.name,
director_date: data.at("director-date"),
sign_image: signature,
stamp_image: stamp,
logo_image: logo,
is_research: data.at("is-research"),
is_intermediate: data.at("is-intermediate"),
stage_num: data.at("stage-number"),
vol_num: data.at("volume-number"),
title: data.title,
theme: data.theme,
contract_num: data.at("contract-number"),
contract_date: data.at("contract-date"),
city: data.city,
year: data.year,
executors: data.executors,
figure-before: data.at("figure-before"),
figure-after: data.at("figure-after"),
table-before: data.at("table-before"),
table-after: data.at("table-after"),
figure-caption-before: data.at("figure-caption-before"),
figure-caption-after: data.at("figure-caption-after"),
table-caption-before: data.at("table-caption-before"),
table-caption-after: data.at("table-caption-after"),
show_title_page: data.at("show-title-page"),
show_executors: data.at("show-executors"),
show_outline: data.at("show-outline"),
report_mode: mode,
watermark: watermark,
)
}
#let report-profile(
title: "Название работы",
theme: none,
udk: none,
director_date: none,
is_research: false,
is_intermediate: false,
stage_number: none,
volume_number: none,
contract_number: none,
contract_date: none,
city: "Екатеринбург",
year: datetime.today().year(),
executors: (),
appendices: (),
appendix_numbering: "cyrillic",
appendix_start: 1,
bibliographies: (),
show_title_page: true,
show_executors: true,
show_outline: true,
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,
) = {
document-profile(
"report",
render-report,
metadata: (
title: title,
theme: theme,
udk: udk,
director-date: director_date,
is-research: is_research,
is-intermediate: is_intermediate,
stage-number: stage_number,
volume-number: volume_number,
contract-number: contract_number,
contract-date: contract_date,
city: city,
year: year,
executors: executors,
parties: (),
// Общая attachment-модель остаётся для писем и договоров. Отчётные
// приложения являются самодостаточными Typst-файлами.
attachments: attachment-set(),
appendices: appendices,
appendix-numbering: appendix_numbering,
appendix-start: appendix_start,
bibliographies: bibliographies,
show-title-page: show_title_page,
show-executors: show_executors,
show-outline: show_outline,
figure-before: figure_before,
figure-after: figure_after,
table-before: table_before,
table-after: table_after,
figure-caption-before: figure_caption_before,
figure-caption-after: figure_caption_after,
table-caption-before: table_caption_before,
table-caption-after: table_caption_after,
),
normalize: normalize-report,
validate: validate-report,
)
}
+247
View File
@@ -0,0 +1,247 @@
// ==========================================
// PRESENTATION: ССЫЛКИ И БИБЛИОГРАФИИ
// ==========================================
#let ensure-label(lbl, scope) = {
if type(lbl) != label {
panic("Scientia / " + scope + ": ожидался label")
}
lbl
}
// В тексте чаще всего нужен предложный падеж, поэтому он используется без
// второго аргумента. Для явного выбора достаточно одной буквы:
// #vref(<figure-label>, "и") или #vref(<figure-label>, "р").
// Прежние сокращения и полные названия сохранены как понятные альтернативы.
#let case-aliases = (
и: "имен",
им: "имен",
имен: "имен",
именительный: "имен",
р: "род",
род: "род",
родительный: "род",
д: "дат",
дат: "дат",
дательный: "дат",
в: "вин",
вин: "вин",
винительный: "вин",
т: "тв",
тв: "тв",
творительный: "тв",
п: "предл",
пр: "предл",
предл: "предл",
предложный: "предл",
)
#let ensure-case(value) = {
let normalized = if type(value) == str {
case-aliases.at(value, default: none)
} else {
none
}
if normalized == none {
panic(
"Scientia / reference: неизвестный падеж " + repr(value)
+ "; используйте и, р, д, в, т или п",
)
}
normalized
}
#let resolve-ref-element(lbl) = {
let lbl = ensure-label(lbl, "reference")
let elems = query(selector(lbl))
if elems == () { none } else { elems.first() }
}
#let classify-ref-element(el) = {
if el.func() == figure {
if el.kind == table { "table" } else { "image" }
} else if el.func() == math.equation {
"equation"
} else if el.func() == heading {
if el.supplement == [Приложение] { "attachment" } else { "section" }
} else {
"other"
}
}
#let noun-forms = (
image: (
singular: (имен: "рисунок", род: "рисунка", дат: "рисунку", вин: "рисунок", тв: "рисунком", предл: "рисунке"),
plural: (имен: "рисунки", род: "рисунков", дат: "рисункам", вин: "рисунки", тв: "рисунками", предл: "рисунках"),
),
table: (
singular: (имен: "таблица", род: "таблицы", дат: "таблице", вин: "таблицу", тв: "таблицей", предл: "таблице"),
plural: (имен: "таблицы", род: "таблиц", дат: "таблицам", вин: "таблицы", тв: "таблицами", предл: "таблицах"),
),
equation: (
singular: (имен: "формула", род: "формулы", дат: "формуле", вин: "формулу", тв: "формулой", предл: "формуле"),
plural: (имен: "формулы", род: "формул", дат: "формулам", вин: "формулы", тв: "формулами", предл: "формулах"),
),
section: (
singular: (имен: "раздел", род: "раздела", дат: "разделу", вин: "раздел", тв: "разделом", предл: "разделе"),
plural: (имен: "разделы", род: "разделов", дат: "разделам", вин: "разделы", тв: "разделами", предл: "разделах"),
),
attachment: (
singular: (имен: "приложение", род: "приложения", дат: "приложению", вин: "приложение", тв: "приложением", предл: "приложении"),
plural: (имен: "приложения", род: "приложений", дат: "приложениям", вин: "приложения", тв: "приложениями", предл: "приложениях"),
),
other: (
singular: (имен: "элемент", род: "элемента", дат: "элементу", вин: "элемент", тв: "элементом", предл: "элементе"),
plural: (имен: "элементы", род: "элементов", дат: "элементам", вин: "элементы", тв: "элементами", предл: "элементах"),
),
)
#let noun-for-case(kind, grammatical-case, plural: false, capitalized: false) = {
let grammatical-case = ensure-case(grammatical-case)
let forms = noun-forms.at(kind, default: noun-forms.other)
let number-forms = if plural { forms.plural } else { forms.singular }
let noun = number-forms.at(grammatical-case)
if capitalized {
let characters = noun.clusters()
upper(characters.first()) + characters.slice(1).join()
} else {
noun
}
}
#let render-resolved-ref(lbl, grammatical-case, capitalized: false) = {
let el = resolve-ref-element(lbl)
if el == none {
text(fill: red)[Ссылка не найдена]
} else {
let kind = classify-ref-element(el)
let prefix = noun-for-case(kind, grammatical-case, capitalized: capitalized)
if kind == "equation" {
[#prefix #ref(lbl, supplement: none)]
} else {
ref(lbl, supplement: prefix)
}
}
}
#let join-reference-items(items, conjunction) = {
if items.len() == 1 {
items.first()
} else if items.len() == 2 {
[#items.first() #conjunction #items.last()]
} else {
for (index, item) in items.enumerate() {
if index > 0 {
if index == items.len() - 1 { [ #conjunction ] } else { [, ] }
}
item
}
}
}
#let parse-reference-options(options, scope, allow-conjunction: false) = {
let positional = options.pos()
let named = options.named()
let allowed = if allow-conjunction {
("grammatical-case", "capitalized", "conjunction")
} else {
("grammatical-case", "capitalized")
}
for key in named.keys() {
if key not in allowed {
panic("Scientia / " + scope + ": неизвестный параметр " + repr(key))
}
}
if positional.len() > 1 {
panic("Scientia / " + scope + ": после label допустим только один позиционный падеж")
}
if positional.len() == 1 and "grammatical-case" in named {
panic("Scientia / " + scope + ": падеж задан одновременно позиционно и по имени")
}
let grammatical-case = if positional.len() == 1 {
positional.first()
} else {
named.at("grammatical-case", default: "п")
}
let capitalized = named.at("capitalized", default: true)
if type(capitalized) != bool {
panic("Scientia / " + scope + ".capitalized: ожидалось bool")
}
(
grammatical-case: ensure-case(grammatical-case),
capitalized: capitalized,
)
}
#let vref(lbl, ..options) = context {
let parsed = parse-reference-options(options, "vref")
render-resolved-ref(
lbl,
parsed.grammatical-case,
capitalized: parsed.capitalized,
)
}
#let vrefs(labels, ..options) = context {
if type(labels) != array or labels.len() == 0 {
panic("Scientia / vrefs: нужен непустой array labels")
}
let parsed = parse-reference-options(options, "vrefs", allow-conjunction: true)
let grammatical-case = parsed.grammatical-case
let capitalized = parsed.capitalized
let conjunction = options.named().at("conjunction", default: [и])
for item in labels { let _ = ensure-label(item, "vrefs") }
let resolved = labels.map(resolve-ref-element)
let groupable = resolved.all(el => el != none) and resolved.len() > 1
if groupable {
let kind = classify-ref-element(resolved.first())
let same-kind = resolved.all(el => classify-ref-element(el) == kind)
if same-kind {
let prefix = noun-for-case(
kind,
grammatical-case,
plural: true,
capitalized: capitalized,
)
let rendered = labels.map(lbl => ref(lbl, supplement: none))
[#prefix #join-reference-items(rendered, conjunction)]
} else {
let rendered = labels.map(lbl => render-resolved-ref(lbl, grammatical-case, capitalized: capitalized))
join-reference-items(rendered, conjunction)
}
} else {
let rendered = labels.map(lbl => render-resolved-ref(lbl, grammatical-case, capitalized: capitalized))
join-reference-items(rendered, conjunction)
}
}
#let eqref(lbl) = context {
let el = resolve-ref-element(lbl)
if el == none {
text(fill: red)[Ссылка не найдена]
} else if classify-ref-element(el) != "equation" {
text(fill: red)[Метка не является формулой]
} else {
ref(lbl, supplement: none)
}
}
#let render-bibliographies(sections) = {
// Typst 0.15: у каждой bibliography собственный heading. Смещение уровня
// отделяет заголовок библиографии от заголовков отчёта и устраняет конфликт
// нумерации/outline при нескольких независимых списках.
show bibliography: set heading(offset: 1)
for (index, section) in sections.enumerate() {
if index > 0 and section.at("page-break", default: true) {
pagebreak()
}
bibliography(
section.sources,
title: section.title,
full: section.full,
style: section.style,
target: section.target,
group: section.group,
)
}
}