Initial commit
This commit is contained in:
commit
c534d5ce80
163 files changed
+11500
No files matched your search
@@ -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]] }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user