Initial commit

This commit is contained in:
malysheva committed 2026-10-09 04:24:38 +00:00
commit c534d5ce80
163 files changed
+11500

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
// ==========================================
// ВНУТРЕННИЙ API ЯДРА
// .template/lib/index.typ реэкспортирует только поддерживаемую публичную поверхность.
// ==========================================
#import "application/render-document.typ": render-document
#import "domain/document.typ": document-profile, render-options
#import "domain/company.typ": company-profile
#import "domain/parties.typ": party, recipient, signer, approval
#import "domain/attachments.typ": attachment, attachment-set, validate-attachment-set
#import "domain/references.typ": bibliography-section
#import "infrastructure/company-assets.typ": load-company, available-companies
#let document(body, company: "scientia", profile: none, options: (:)) = {
render-document(body, company, profile, options: options)
}
+43
View File
@@ -0,0 +1,43 @@
// ==========================================
// ЛОГИКА ПРИЛОЖЕНИЙ
// ==========================================
#import "shared/numbering.typ": attachment_numbering
// Макрос для приложений отчёта. Каждый подключённый файл должен начинаться
// заголовком первого уровня: = Название приложения <appendix-label>.
#let make_appendices(body, numbering: "cyrillic", start: 1) = {
let appendix-numbering = (..nums) => {
attachment_numbering(numbering, nums.pos().last())
}
counter(heading).update(start - 1)
// Supplement одновременно используется штатным ref и системой падежей vref.
set heading(numbering: appendix-numbering, supplement: [Приложение])
show heading.where(level: 1): it => {
pagebreak()
// Заголовок 1 уровня в приложениях выровнен по правому краю, жирным и чуть меньшим шрифтом
set align(right)
set text(weight: "bold", size: 12pt)
set par(leading: 0.7em)
let num = attachment_numbering(numbering, counter(heading).get().first())
if num == "" {
[Приложение. #it.body]
} else {
[Приложение #num. #it.body]
}
v(0.5em)
}
set figure(numbering: (..nums) => {
let h = counter(heading).get().first()
let prefix = attachment_numbering(numbering, h)
if prefix == "" {
str(nums.pos().last())
} else {
prefix + "." + str(nums.pos().last())
}
})
body
}
@@ -0,0 +1,45 @@
// ==========================================
// APPLICATION: СБОРКА ДОКУМЕНТА
// ==========================================
#import "../domain/document.typ": document-context, render-options, validate-profile-contract
#import "../domain/company.typ": validate-company
#import "../domain/parties.typ": validate-parties
#import "../domain/attachments.typ": attachment-set, validate-attachment-set
#import "../domain/references.typ": validate-bibliographies
#import "../infrastructure/company-assets.typ": load-company
#let resolve-company(value) = {
if type(value) == str {
validate-company(load-company(value))
} else {
validate-company(value)
}
}
#let render-document(
body,
company,
profile,
options: (:),
) = {
let company = resolve-company(company)
let profile = validate-profile-contract(profile)
let normalized = (profile.normalize)(profile.metadata)
let validated = (profile.validate)(normalized)
let metadata = if validated == none { normalized } else { validated }
let parties = validate-parties(metadata.at("parties", default: ()))
let attachments = validate-attachment-set(
metadata.at("attachments", default: attachment-set()),
)
let bibliographies = validate-bibliographies(metadata.at("bibliographies", default: ()))
let ctx = document-context(
company,
profile,
metadata,
render-options(options: options),
parties: parties,
attachments: attachments,
bibliographies: bibliographies,
)
(profile.render)(body, ctx)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220">
<rect x="8" y="8" width="584" height="204" rx="20" fill="#f5f5f5" stroke="#999" stroke-width="8" stroke-dasharray="18 12"/>
<text x="300" y="126" text-anchor="middle" font-family="Arial, sans-serif" font-size="44" font-weight="700" fill="#777">ЛОГОТИП</text>
</svg>

After

Width:  |  Height:  |  Size: 340 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 180">
<g fill="none" stroke="#8a8a8a" stroke-width="8" stroke-linecap="round" opacity="0.72">
<path d="M205 34 L395 146"/>
<path d="M395 34 L205 146"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 233 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 420">
<circle cx="210" cy="210" r="178" fill="none" stroke="#8a8a8a" stroke-width="8" opacity="0.72"/>
</svg>

After

Width:  |  Height:  |  Size: 169 B

+24
View File
@@ -0,0 +1,24 @@
// ==========================================
// ДАННЫЕ КОМПАНИИ И ДЕФОЛТЫ ШАБЛОНА
// ==========================================
#let load-company-data(company-id: "too") = {
json("../companies/" + company-id + "/data.json")
}
#let company-defaults(company-id: "too") = {
let data = load-company-data(company-id: company-id)
let company-name = data.at("name", default: "ООО «Скиентия»")
let director-title = data.at("director-title", default: "Директор")
(
company_info: data.at(
"company_info",
default: "ООО «Скиентия» ИНН 6686148633\nул. Шейнкмана, стр. 9, офис 65\nг. Екатеринбург, 620014, Россия\n+7 (922) 203-24-60 ☏\ninfo@scientia.ru 🖂",
),
director_company: director-title + " " + company-name,
director_name: data.at("director-name", default: "Мусихин А.С."),
city: data.at("city", default: "Екатеринбург"),
sign_image: data.at("sign_image", default: none),
stamp_image: data.at("stamp_image", default: none),
)
}
+369
View File
@@ -0,0 +1,369 @@
// ==========================================
// ПЕРЕИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ
// ==========================================
#import "presentation/lists.typ": resolve-list-layout
// Корпоративная таблица с оранжевой шапкой.
// Для простых таблиц первая строка по-прежнему считается шапкой.
// Для сложной шапки используйте `header: (...)`.
#let corp_table(
columns: auto,
table-align: center, // center | left | right
row_breakable: true,
page_break: "fit",
text-size: 12pt,
leading: 0.65em,
header-leading: auto,
body-leading: auto,
spacing: auto,
header-spacing: auto,
body-spacing: auto,
justify: auto,
first-line-indent: 0pt,
hyphenate: auto,
header-hyphenate: auto,
body-hyphenate: auto,
inset: 0% + 5pt,
header-inset: auto,
body-inset: auto,
list-layout: auto,
list-indent: auto,
list-body-indent: auto,
header: auto,
body: auto,
repeat_header: true,
header_fill: rgb("ffd35f"),
continuation: true,
continuation_text: "Продолжение таблицы",
continuation_with_number: true,
continuation_gap: 0.5em,
..args,
) = {
let resolve-table-align = value => {
if value == left or value == "left" {
left
} else if value == right or value == "right" {
right
} else {
center
}
}
let block-align = resolve-table-align(table-align)
let cols_count = if type(columns) == int {
columns
} else if type(columns) == array {
columns.len()
} else {
1
}
let pos_args = args.pos()
let named_args = args.named()
let normalize-cells = value => {
if value == auto or value == none {
()
} else if type(value) == array {
value
} else {
(value,)
}
}
let span-of = (item, key) => {
if type(item) == content and item.func() == table.cell {
item.at(key, default: 1)
} else {
1
}
}
let decay-carries = carries => range(carries.len()).map(i => calc.max(carries.at(i) - 1, 0))
let advance-slot(row, col, carries) = {
if col >= cols_count {
advance-slot(row + 1, 0, decay-carries(carries))
} else if carries.at(col) > 0 {
advance-slot(row, col + 1, carries)
} else {
(row, col, carries)
}
}
let update-carries = (carries, col, colspan, rowspan) => {
range(carries.len()).map(i => if i >= col and i < col + colspan { calc.max(carries.at(i), rowspan) } else { carries.at(i) })
}
let update-filled = (filled, row, colspan, rowspan) => {
range(filled.len()).map(i => if i >= row and i < row + rowspan { filled.at(i) + colspan } else { filled.at(i) })
}
let rows-filled(filled, row-count) = {
if row-count <= 0 {
true
} else if filled.at(row-count - 1) == cols_count {
rows-filled(filled, row-count - 1)
} else {
false
}
}
let infer-header-rows(cells, idx: 0, row: 0, col: 0, carries: range(cols_count).map(_ => 0), max-row: -1) = {
if idx >= cells.len() {
calc.max(max-row + 1, 0)
} else {
let item = cells.at(idx)
let slot = advance-slot(row, col, carries)
let place-row = slot.at(0)
let place-col = slot.at(1)
let place-carries = slot.at(2)
let colspan = span-of(item, "colspan")
let rowspan = span-of(item, "rowspan")
let next-carries = update-carries(place-carries, place-col, colspan, rowspan)
infer-header-rows(
cells,
idx: idx + 1,
row: place-row,
col: place-col + colspan,
carries: next-carries,
max-row: calc.max(max-row, place-row + rowspan - 1),
)
}
}
let infer-auto-header-len(
cells,
idx: 0,
row: 0,
col: 0,
carries: range(cols_count).map(_ => 0),
filled: none,
first-row-max: 1,
header-rows: none,
) = {
let filled = if filled == none { range(calc.max(cells.len(), 1) + 2).map(_ => 0) } else { filled }
if idx >= cells.len() {
cells.len()
} else {
let item = cells.at(idx)
let slot = advance-slot(row, col, carries)
let place-row = slot.at(0)
let place-col = slot.at(1)
let place-carries = slot.at(2)
let colspan = span-of(item, "colspan")
let rowspan = span-of(item, "rowspan")
let next-carries = update-carries(place-carries, place-col, colspan, rowspan)
let next-filled = update-filled(filled, place-row, colspan, rowspan)
let next-first-row-max = if place-row == 0 { calc.max(first-row-max, rowspan) } else { first-row-max }
let next-header-rows = if header-rows == none and next-filled.at(0) == cols_count { next-first-row-max } else { header-rows }
if next-header-rows != none and rows-filled(next-filled, next-header-rows) {
idx + 1
} else {
infer-auto-header-len(
cells,
idx: idx + 1,
row: place-row,
col: place-col + colspan,
carries: next-carries,
filled: next-filled,
first-row-max: next-first-row-max,
header-rows: next-header-rows,
)
}
}
}
let header_cells = if header == auto {
let h_len = infer-auto-header-len(pos_args)
pos_args.slice(0, h_len)
} else {
normalize-cells(header)
}
let body_cells = if body == auto {
if header == auto {
pos_args.slice(header_cells.len())
} else {
pos_args
}
} else {
normalize-cells(body)
}
let header_row_count = if header_cells.len() > 0 { infer-header-rows(header_cells) } else { 0 }
let has_header = header_row_count > 0
if "align" not in named_args {
// Центрирование самой таблицы не должно наследоваться текстом ячеек.
// Шапка по умолчанию центрируется, содержимое читается слева направо.
named_args.insert(
"align",
(x, y) => if has_header and y >= 1 and y <= header_row_count {
center + horizon
} else {
left + top
},
)
}
if type(row_breakable) != bool {
panic("Scientia / corp-table.row-breakable: ожидался bool")
}
let cell_breakable = if type(page_break) == bool {
page_break
} else if page_break == "fit" {
row_breakable
} else if page_break == "cell" {
false
} else {
panic("Scientia / corp-table.page-break: ожидались bool, 'fit' или 'cell'")
}
let resolved-header-leading = if header-leading == auto { leading } else { header-leading }
let resolved-body-leading = if body-leading == auto { leading } else { body-leading }
let resolved-header-spacing = if header-spacing == auto { spacing } else { header-spacing }
let resolved-body-spacing = if body-spacing == auto { spacing } else { body-spacing }
let resolved-header-hyphenate = if header-hyphenate == auto { hyphenate } else { header-hyphenate }
let resolved-body-hyphenate = if body-hyphenate == auto { hyphenate } else { body-hyphenate }
let resolved-header-inset = if header-inset == auto { inset } else { header-inset }
let resolved-body-inset = if body-inset == auto { inset } else { body-inset }
let resolved-list-layout = resolve-list-layout(
list-layout,
kind: "list",
level-indent: list-indent,
body-indent: list-body-indent,
)
let resolved-enum-layout = resolve-list-layout(
list-layout,
kind: "enum",
level-indent: list-indent,
body-indent: list-body-indent,
)
let list-options = (:)
let enum-options = (:)
if resolved-list-layout.level-indent != auto {
list-options.insert("indent", resolved-list-layout.level-indent)
}
if resolved-list-layout.body-indent != auto {
list-options.insert("body-indent", resolved-list-layout.body-indent)
}
if resolved-enum-layout.level-indent != auto {
enum-options.insert("indent", resolved-enum-layout.level-indent)
}
if resolved-enum-layout.body-indent != auto {
enum-options.insert("body-indent", resolved-enum-layout.body-indent)
}
let cont_cell = table.cell(colspan: cols_count, stroke: none, fill: none, align: left, inset: 0pt)[
#context {
if not continuation {
v(0pt)
} else {
let current_page = counter(page).get().first()
let tables_before = query(selector(table).before(here()))
let start_page = if tables_before == () {
current_page
} else {
counter(page).at(tables_before.last().location()).first()
}
if current_page > start_page {
let continuation_label = if type(continuation_text) == str {
text(continuation_text.replace(" ", " "))
} else {
continuation_text
}
let continuation_title = if continuation_with_number {
[#continuation_label #counter(figure.where(kind: table)).display()]
} else {
continuation_label
}
set text(weight: "regular", size: text-size, hyphenate: false)
pad(bottom: continuation_gap)[#box(continuation_title)]
} else {
v(0pt)
}
}
}
]
set text(size: text-size)
set table.cell(breakable: cell_breakable)
show table.cell: it => {
let is-header = has_header and it.y >= 1 and it.y <= header_row_count
let current-leading = if is-header { resolved-header-leading } else { resolved-body-leading }
let current-spacing = if is-header { resolved-header-spacing } else { resolved-body-spacing }
let current-hyphenate = if is-header { resolved-header-hyphenate } else { resolved-body-hyphenate }
// Настройка принадлежит таблице и наследуется списками в её ячейках.
// Локальная оболочка bullet-list/numbered-list может переопределить её.
set list(..list-options)
set enum(..enum-options)
// Важно: block(width:100%, it) ломает align: ... + horizon (вертикальное центрирование).
// Решение: возвращаем `it` напрямую, а все set-правила применяем через вложенные if-else,
// где `it` находится ВНУТРИ той же ветки, что и set — иначе set не распространяется на it.
// spacing применяется снаружи через замыкание, чтобы не дублировать внутренние ветки.
set par(leading: current-leading)
let styled-it = () => {
if is-header {
set par(first-line-indent: 0pt)
set text(weight: "bold")
if justify != auto {
set par(justify: justify)
if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
} else if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
} else if first-line-indent != auto {
set par(first-line-indent: first-line-indent)
if justify != auto {
set par(justify: justify)
if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
} else if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
} else {
if justify != auto {
set par(justify: justify)
if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
} else if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
}
}
if current-spacing != auto {
set par(spacing: current-spacing)
styled-it()
} else {
styled-it()
}
}
align(block-align)[
#table(
columns: columns,
stroke: 0.5pt,
fill: (x, y) => if has_header and y >= 1 and y <= header_row_count { header_fill } else { none },
inset: (x, y) => if has_header and y >= 1 and y <= header_row_count { resolved-header-inset } else { resolved-body-inset },
..named_args,
..if has_header {
(
table.header(
repeat: repeat_header,
cont_cell,
..header_cells,
),
)
} else {
()
},
..body_cells
)
]
}
// Объект формулы по ГОСТ:
// центрирование формулы и номер справа настраиваются глобально в report.typ.
// Использование: #formula($ ... $) <eq_label>
#let formula(body, ..args) = math.equation(
body,
block: true,
..args,
)
+77
View File
@@ -0,0 +1,77 @@
// ==========================================
// DOMAIN: ПРИЛОЖЕНИЯ
// ==========================================
#import "document.typ": fail
#let attachment(
id,
title,
body,
subtitle: none,
number: auto,
outlined: true,
) = {
if type(id) != str or id.trim() == "" {
fail("attachment.id", "нужна непустая строка")
}
if type(title) not in (str, content) {
fail("attachment.title", "ожидались str или content")
}
if type(body) not in (content, function) {
fail("attachment.body", "ожидались content или function")
}
if subtitle != none and type(subtitle) not in (str, content) {
fail("attachment.subtitle", "ожидались none, str или content")
}
(
kind: "attachment",
id: id,
title: title,
subtitle: subtitle,
body: body,
number: number,
outlined: outlined,
)
}
#let attachment-set(items: (), numbering: "arabic", start: 1) = {
if type(items) != array {
fail("attachments.items", "ожидался array")
}
if numbering not in ("arabic", "cyrillic", "none") {
fail("attachments.numbering", "допустимы arabic, cyrillic, none")
}
if type(start) != int or start < 1 {
fail("attachments.start", "нужно положительное целое число")
}
let seen = ()
for item in items {
if type(item) != dictionary or item.at("kind", default: none) != "attachment" {
fail("attachments.items", "каждый элемент должен быть attachment")
}
if item.id in seen {
fail("attachments.items", "повторяющийся id " + repr(item.id))
}
seen.push(item.id)
}
(
kind: "attachment-set",
items: items,
numbering: numbering,
start: start,
)
}
#let validate-attachment-set(value) = {
if type(value) == array {
attachment-set(items: value)
} else if type(value) != dictionary or value.at("kind", default: none) != "attachment-set" {
fail("attachments", "ожидались array или attachment-set")
} else {
attachment-set(
items: value.at("items", default: ()),
numbering: value.at("numbering", default: "arabic"),
start: value.at("start", default: 1),
)
}
}
+146
View File
@@ -0,0 +1,146 @@
// ==========================================
// DOMAIN: ПРОФИЛЬ ОРГАНИЗАЦИИ
// ==========================================
#import "document.typ": fail, ensure-dictionary
#let optional-text(value) = {
if value == none {
none
} else if type(value) != str {
value
} else if value.trim() == "" {
none
} else {
value
}
}
#let fill-defaults(defaults, values) = {
let result = defaults
for (key, value) in values {
result.insert(key, optional-text(value))
}
result
}
#let company-profile(
id,
legal,
contacts: (:),
banking: (:),
brand: (:),
director: (:),
resources: (:),
) = {
if type(id) != str or id.trim() == "" {
fail("company.id", "нужна непустая строка")
}
let legal = fill-defaults(
(
name: none,
short-name: none,
jurisdiction: none,
inn: none,
kpp: none,
ogrn: none,
bin: none,
kbe: none,
),
ensure-dictionary(legal, "company.legal"),
)
let contacts = fill-defaults(
(
email: none,
website: none,
phone: none,
address: none,
address-en: none,
city: none,
summary: none,
),
ensure-dictionary(contacts, "company.contacts"),
)
let banking = fill-defaults(
(
bank: none,
account: none,
correspondent-account: none,
bik: none,
iban: none,
),
ensure-dictionary(banking, "company.banking"),
)
let brand = fill-defaults(
(color: rgb("e39f49"),),
ensure-dictionary(brand, "company.brand"),
)
let director = fill-defaults(
(title: none, name: none),
ensure-dictionary(director, "company.director"),
)
let resources = fill-defaults(
(logo: none, signature: none, stamp: none),
ensure-dictionary(resources, "company.resources"),
)
let profile = (
kind: "company-profile",
id: id,
legal: legal,
contacts: contacts,
banking: banking,
brand: brand,
director: director,
resources: resources,
)
profile
}
#let validate-company(profile) = {
let profile = ensure-dictionary(profile, "company")
if profile.at("kind", default: none) != "company-profile" {
fail("company.kind", "ожидался company-profile")
}
for key in ("legal", "contacts", "banking", "brand", "director", "resources") {
if key not in profile or type(profile.at(key)) != dictionary {
fail("company." + key, "ожидался dictionary")
}
}
let name = profile.legal.at("name", default: none)
if type(name) != str or name.trim() == "" {
fail("company.legal.name", "полное наименование обязательно")
}
let jurisdiction = profile.legal.at("jurisdiction", default: none)
if type(jurisdiction) != str or jurisdiction.trim() == "" {
fail("company.legal.jurisdiction", "юрисдикция обязательна")
}
for key in ("title", "name") {
let value = profile.director.at(key, default: none)
if type(value) != str or value.trim() == "" {
fail("company.director." + key, "поле обязательно")
}
}
if type(profile.brand.color) != color {
fail("company.brand.color", "ожидался color")
}
for key in ("logo", "signature", "stamp") {
let resource = profile.resources.at(key, default: none)
if resource != none and type(resource) not in (path, content) {
fail("company.resources." + key, "ожидались none, path или content")
}
}
profile
}
#let company-display-name(profile, short: false) = {
let profile = validate-company(profile)
if short {
profile.legal.at("short-name", default: profile.legal.name)
} else {
profile.legal.name
}
}
#let company-resource(profile, key) = {
validate-company(profile).resources.at(key, default: none)
}
+115
View File
@@ -0,0 +1,115 @@
// ==========================================
// DOMAIN: ДОКУМЕНТ И КОНТРАКТ ПРОФИЛЯ
// ==========================================
#let fail(scope, message) = panic("Scientia / " + scope + ": " + message)
#let ensure-dictionary(value, scope) = {
if type(value) != dictionary {
fail(scope, "ожидался dictionary, получено " + repr(type(value)))
}
value
}
#let merge-known(defaults, overrides, scope: "options", allow-extra: false) = {
let defaults = ensure-dictionary(defaults, scope + ".defaults")
let overrides = ensure-dictionary(overrides, scope)
let result = defaults
for (key, value) in overrides {
if not allow-extra and key not in defaults {
fail(scope, "неизвестный параметр " + repr(key))
}
result.insert(key, value)
}
result
}
#let render-options(options: (:)) = {
let resolved = merge-known(
(
mode: "final",
watermark: none,
media-policy: "placeholder",
diagnostics: true,
),
options,
scope: "render-options",
)
if resolved.mode not in ("final", "draft", "clean-copy") {
fail("render-options.mode", "допустимы final, draft, clean-copy")
}
if resolved.at("media-policy") not in ("hide", "placeholder", "reserve-space") {
fail("render-options.media-policy", "допустимы hide, placeholder, reserve-space")
}
resolved
}
#let document-profile(
id,
render,
metadata: (:),
normalize: value => value,
validate: value => value,
) = {
if type(id) != str or id.trim() == "" {
fail("document-profile.id", "нужна непустая строка")
}
if type(metadata) != dictionary {
fail("document-profile.metadata", "ожидался dictionary")
}
if type(normalize) != function {
fail("document-profile.normalize", "ожидалась function")
}
if type(validate) != function {
fail("document-profile.validate", "ожидалась function")
}
if type(render) != function {
fail("document-profile.render", "ожидалась function")
}
(
kind: "document-profile",
id: id,
metadata: metadata,
normalize: normalize,
validate: validate,
render: render,
)
}
#let validate-profile-contract(profile) = {
let profile = ensure-dictionary(profile, "profile")
if profile.at("kind", default: none) != "document-profile" {
fail("profile.kind", "ожидался document-profile")
}
for key in ("id", "metadata", "normalize", "validate", "render") {
if key not in profile {
fail("profile", "отсутствует обязательное поле " + repr(key))
}
}
if type(profile.normalize) != function or type(profile.validate) != function or type(profile.render) != function {
fail("profile", "normalize, validate и render должны быть functions")
}
profile
}
#let document-context(
company,
profile,
metadata,
options,
parties: (),
attachments: (),
bibliographies: (),
) = {
(
kind: "document-context",
company: company,
profile-id: profile.id,
metadata: ensure-dictionary(metadata, "document-context.metadata"),
options: render-options(options: options),
parties: parties,
attachments: attachments,
bibliographies: bibliographies,
)
}
+124
View File
@@ -0,0 +1,124 @@
// ==========================================
// DOMAIN: СТОРОНЫ, АДРЕСАТЫ И ПОДПИСАНТЫ
// ==========================================
#import "document.typ": fail, ensure-dictionary
#let normalize-text(value, scope, required: false) = {
if value == none {
if required { fail(scope, "поле обязательно") }
none
} else if type(value) != str {
fail(scope, "ожидалась строка")
} else if value.trim() == "" {
if required { fail(scope, "поле не может быть пустым") }
none
} else {
value
}
}
#let signer(
name,
title,
basis: none,
signature: none,
stamp: none,
) = {
for (key, resource) in (("signature", signature), ("stamp", stamp)) {
if resource != none and type(resource) not in (path, content) {
fail("signer." + key, "ожидались none, path или content")
}
}
(
kind: "signer",
name: normalize-text(name, "signer.name", required: true),
title: normalize-text(title, "signer.title", required: true),
basis: normalize-text(basis, "signer.basis"),
signature: signature,
stamp: stamp,
)
}
#let recipient(
company: none,
title: none,
name: none,
address: none,
) = {
let resolved = (
kind: "recipient",
company: normalize-text(company, "recipient.company"),
title: normalize-text(title, "recipient.title"),
name: normalize-text(name, "recipient.name"),
address: normalize-text(address, "recipient.address"),
)
if resolved.company == none and resolved.name == none {
fail("recipient", "нужно указать организацию или имя адресата")
}
resolved
}
#let party(
id,
role,
name,
legal: (:),
contacts: (:),
banking: (:),
representative: none,
) = {
if representative != none and (
type(representative) != dictionary
or representative.at("kind", default: none) != "signer"
) {
fail("party.representative", "ожидались none или signer")
}
(
kind: "party",
id: normalize-text(id, "party.id", required: true),
role: normalize-text(role, "party.role", required: true),
name: normalize-text(name, "party.name", required: true),
legal: ensure-dictionary(legal, "party.legal"),
contacts: ensure-dictionary(contacts, "party.contacts"),
banking: ensure-dictionary(banking, "party.banking"),
representative: representative,
)
}
#let approval(signer, date: none, label: "УТВЕРЖДАЮ") = {
if type(signer) != dictionary or signer.at("kind", default: none) != "signer" {
fail("approval.signer", "ожидался signer")
}
(
kind: "approval",
signer: signer,
date: normalize-text(date, "approval.date"),
label: normalize-text(label, "approval.label", required: true),
)
}
#let validate-parties(parties, minimum: 0) = {
if type(parties) != array {
fail("parties", "ожидался array")
}
if parties.len() < minimum {
fail("parties", "нужно не менее " + str(minimum) + " сторон")
}
let seen = ()
for item in parties {
if type(item) != dictionary or item.at("kind", default: none) != "party" {
fail("parties", "каждый элемент должен быть party")
}
if item.id in seen {
fail("parties", "повторяющийся id " + repr(item.id))
}
if item.representative != none and (
type(item.representative) != dictionary
or item.representative.at("kind", default: none) != "signer"
) {
fail("parties", "representative должен быть signer")
}
seen.push(item.id)
}
parties
}
+62
View File
@@ -0,0 +1,62 @@
// ==========================================
// DOMAIN: БИБЛИОГРАФИЧЕСКИЕ СЕКЦИИ
// ==========================================
#import "document.typ": fail
#let bibliography-section(
id,
sources,
title: auto,
style: "gost-r-705-2008-numeric",
target: auto,
group: auto,
full: false,
page_break: true,
) = {
if type(id) != str or id.trim() == "" {
fail("bibliography.id", "нужна непустая строка")
}
let normalized-sources = if type(sources) == array { sources } else { (sources,) }
if normalized-sources.len() == 0 {
fail("bibliography.sources", "нужен хотя бы один источник")
}
for source in normalized-sources {
if type(source) not in (str, path, bytes) {
fail("bibliography.sources", "ожидались str, path или bytes")
}
}
if type(full) != bool {
fail("bibliography.full", "ожидался bool")
}
if type(page_break) != bool {
fail("bibliography.page-break", "ожидался bool")
}
(
kind: "bibliography-section",
id: id,
sources: normalized-sources,
title: title,
style: style,
target: target,
group: group,
full: full,
page-break: page_break,
)
}
#let validate-bibliographies(sections) = {
if type(sections) != array {
fail("bibliographies", "ожидался array")
}
let seen = ()
for section in sections {
if type(section) != dictionary or section.at("kind", default: none) != "bibliography-section" {
fail("bibliographies", "каждый элемент должен быть bibliography-section")
}
if section.id in seen {
fail("bibliographies", "повторяющийся id " + repr(section.id))
}
seen.push(section.id)
}
sections
}
+75
View File
@@ -0,0 +1,75 @@
// ==========================================
// SCIENTIA DOCUMENTS — ПУБЛИЧНЫЙ ФАСАД
// ==========================================
// Единственная точка входа для пользовательских документов.
#import "api.typ" as api
#import "presentation/profiles/index.typ" as profile-module
#import "presentation/foundation.typ" as foundation-module
#import "presentation/components.typ" as component-module
#import "presentation/references.typ" as reference-module
#import "presentation/lists.typ" as list-module
#import "infrastructure/employees.typ" as employee-module
// Сборка документа и доменные конструкторы.
#let document(
body,
company: "scientia",
profile: profile-module.report(),
options: (:),
) = api.document(body, company: company, profile: profile, options: options)
#let document-profile = api.document-profile
#let company-profile = api.company-profile
#let party = api.party
#let recipient = api.recipient
#let signer = api.signer
#let approval = api.approval
#let attachment = api.attachment
#let attachment-set = api.attachment-set
#let validate-attachment-set = api.validate-attachment-set
#let bibliography-section = api.bibliography-section
#let load-company = api.load-company
#let available-companies = api.available-companies
#let render-options = api.render-options
// Публичный справочник сотрудников и безопасное подключение private media.
#let employee-directory = employee-module.employee-directory
#let empty-private-settings = employee-module.empty-private-settings
#let private-company-media = employee-module.private-company-media
#let report-executor = employee-module.report-executor
// Профили доступны и через namespace, и короткими именами.
#let profiles = profile-module
#let report = profile-module.report
#let letter = profile-module.letter
#let commercial-offer = profile-module.commercial_offer
#let contract = profile-module.contract
#let contract-section = profile-module.contract_section
#let commercial-terms = profile-module.commercial_terms
// Стабильные переиспользуемые элементы представления.
#let components = component-module
#let foundation = foundation-module
#let apply-foundation = foundation-module.apply-foundation
#let render-media-slot = foundation-module.render-media-slot
#let corp-table = component-module.corp_table
#let formula = component-module.formula
#let info-block = component-module.info-block
#let company-logo = component-module.company-logo
#let signature-block = component-module.signature-block
#let approval-block = component-module.approval-block
#let requisites-table = component-module.requisites-table
// Настраиваемые многоуровневые списки.
#let bullet-list = list-module.bullet-list
#let numbered-list = list-module.numbered-list
#let list-scheme = list-module.list-scheme
#let list-level = list-module.list-level
#let list-numbering = list-module.list-numbering
#let list-schemes = list-module.list-schemes
// Ссылки и библиографии.
#let references = reference-module
#let vref = reference-module.vref
#let vrefs = reference-module.vrefs
#let eqref = reference-module.eqref
@@ -0,0 +1,109 @@
// ==========================================
// INFRASTRUCTURE: ПУБЛИЧНЫЕ ДАННЫЕ КОМПАНИЙ
// ==========================================
#import "../domain/company.typ": company-profile
#import "../domain/document.typ": fail
#let available-companies() = ("scientia", "technology", "too", "test-company")
#let company-data-path(id) = {
if id == "scientia" {
path("../../companies/scientia/data.json")
} else if id == "technology" {
path("../../companies/technology/data.json")
} else if id == "too" {
path("../../companies/too/data.json")
} else if id == "test-company" {
path("../../companies/test-company/data.json")
} else {
fail(
"load-company.id",
"неизвестная компания " + repr(id) + "; доступны " + available-companies().join(", "),
)
}
}
#let internal-asset(value) = {
let value = if value == "" { none } else { value }
if value == none {
none
} else if type(value) == path or type(value) == content {
value
} else if type(value) == str and value.starts-with("companies/") {
path("../../" + value)
} else if type(value) == str {
fail("company-asset", "путь должен начинаться с companies/: " + repr(value))
} else {
fail("company-asset", "ожидались none, str, path или content")
}
}
#let external-resource(value, field) = {
if value == none or type(value) == path or type(value) == content {
value
} else {
fail("load-company." + field, "ожидались none, path или content")
}
}
#let normalize-company-data(id, raw) = {
let jurisdiction = if id == "too" { "KZ" } else { "RU" }
let color-value = raw.at("brand-color", default: "#e39f49")
let brand-color = if type(color-value) == color { color-value } else { rgb(color-value) }
company-profile(
id,
(
name: raw.at("name"),
short-name: raw.at("short-name", default: raw.at("name")),
jurisdiction: jurisdiction,
inn: raw.at("inn", default: none),
kpp: raw.at("kpp", default: none),
ogrn: raw.at("ogrn", default: none),
bin: raw.at("bin", default: none),
kbe: raw.at("kbe", default: none),
),
contacts: (
email: raw.at("email", default: none),
website: raw.at("website", default: none),
phone: raw.at("phone", default: none),
address: raw.at("address", default: none),
address-en: raw.at("address_en", default: none),
city: raw.at("city", default: none),
summary: raw.at("company_info", default: none),
),
banking: (
bank: raw.at("bank", default: none),
correspondent-account: raw.at("ks", default: none),
bik: raw.at("bik", default: none),
),
brand: (color: brand-color),
director: (
title: raw.at("director-title", default: "Директор"),
name: raw.at("director-name", default: none),
),
resources: (
logo: internal-asset(raw.at("logo_image", default: none)),
signature: internal-asset(raw.at("sign_image", default: none)),
stamp: internal-asset(raw.at("stamp_image", default: none)),
),
)
}
#let load-company(
id,
logo: auto,
signature: auto,
stamp: auto,
) = {
if type(id) != str {
fail("load-company.id", "ожидалась строка")
}
let company = normalize-company-data(id, json(company-data-path(id)))
let resources = company.resources
if logo != auto { resources.insert("logo", external-resource(logo, "logo")) }
if signature != auto { resources.insert("signature", external-resource(signature, "signature")) }
if stamp != auto { resources.insert("stamp", external-resource(stamp, "stamp")) }
company.insert("resources", resources)
company
}
+207
View File
@@ -0,0 +1,207 @@
// =============================================================
// INFRASTRUCTURE: ПУБЛИЧНЫЙ СПРАВОЧНИК СОТРУДНИКОВ И ПОДПИСЕЙ
// =============================================================
// ФИО, обычная должность и имя файла не являются приватными.
// Наличие подписи и её индивидуальное смещение задаются только в
// игнорируемом Git файле /.private/settings.typ.
#import "../domain/document.typ": fail
#let employee-directory = (
musikhin: (
name: "Мусихин А.С.",
default-role: "Ответственный исполнитель",
signature-file: "Musikhin.png",
),
guzeev: (
name: "Гузеев И.А.",
default-role: "Главный геомеханик",
signature-file: "Guzeev.png",
),
fedorov: (
name: "Федоров Д.А.",
default-role: "Инженер-геомеханик",
signature-file: "Fedorov.png",
),
ilyasov: (
name: "Ильясов Б.Т.",
default-role: "Технический директор, к.т.н.",
signature-file: "Ilyasov.png",
),
khimichev: (
name: "Химичев С.С.",
default-role: "Инженер-геомеханик",
signature-file: "Khimichev.png",
),
brusnicin: (
name: "Брусницын И.В.",
default-role: "Инженер-геомеханик",
signature-file: "Brusnicin.png",
),
ozornin: (
name: "Озорнин Д.А.",
default-role: "Геолог",
signature-file: "Ozornin.png",
),
buhartdinov: (
name: "Бухартдинов А.С.",
default-role: "Главный маркшейдер",
signature-file: "Buhartdinov.png",
),
tkachenko: (
name: "Ткаченко А.С.",
default-role: "Инженер-геомеханик",
signature-file: "Tkachenko.png",
),
moshin: (
name: "Мошин В.Е.",
default-role: "Гидрогеолог",
signature-file: "Moshin.png",
),
mitrokhin: (
name: "Митрохин В.А.",
default-role: "Главный гидрогеолог",
signature-file: "Mitrokhin.png",
),
luzina: (
name: "Лузина М.В.",
default-role: "Геолог",
signature-file: "Luzina.png",
),
balandin: (
name: "Баландин А.",
default-role: "Геолог",
signature-file: "Balandin.png",
),
baigali: (
name: "Байгали Р.К.",
default-role: "Инженер-геомеханик",
signature-file: "Baigali.png",
),
sugatov: (
name: "Сугатов Н.С.",
default-role: "Инженер-геомеханик",
signature-file: "Sugatov.png",
),
soluyanov: (
name: "Солуянов Н.О.",
default-role: "Ведущий геомеханик",
signature-file: "Soluyanov.png",
),
ecenkov: (
name: "ЛЕценков И.А.",
default-role: "Инженер-геомеханик",
signature-file: "Ecenkov.png",
),
savin: (
name: "Савин Д.А.",
default-role: "Делопроизводитель",
signature-file: "Savin.png",
),
trescov: (
name: "Тресцов Н.Н.",
default-role: "Геолог",
signature-file: "Trescov.png",
),
)
#let empty-private-settings = (
companies: (:),
signatures: (:),
)
#let validate-private-settings(settings) = {
if type(settings) != dictionary {
fail("private-settings", "ожидался словарь из .private/settings.typ")
}
for field in ("companies", "signatures") {
if type(settings.at(field, default: (:))) != dictionary {
fail("private-settings." + field, "ожидался словарь")
}
}
settings
}
#let private-company-media(settings, company-id) = {
let settings = validate-private-settings(settings)
let company = settings.companies.at(company-id, default: none)
if company == none {
(signature: none, stamp: none)
} else {
if type(company) != dictionary {
fail("private-settings.companies." + company-id, "ожидался словарь")
}
let has-signature = company.at("signature", default: false)
let has-stamp = company.at("stamp", default: false)
if type(has-signature) != bool or type(has-stamp) != bool {
fail(
"private-settings.companies." + company-id,
"поля signature и stamp должны быть true или false",
)
}
(
signature: if has-signature {
path("/.private/" + company-id + "/sign.png")
} else {
none
},
stamp: if has-stamp {
path("/.private/" + company-id + "/stamp.png")
} else {
none
},
)
}
}
#let report-executor(employee-id, role: auto, private-settings: empty-private-settings) = {
if type(employee-id) != str or not employee-directory.keys().contains(employee-id) {
fail(
"report-executor.employee-id",
"неизвестный сотрудник " + repr(employee-id) + "; доступны "
+ employee-directory.keys().join(", "),
)
}
let person = employee-directory.at(employee-id)
let resolved-role = if role == auto { person.default-role } else { role }
if type(resolved-role) != str or resolved-role.trim() == "" {
fail("report-executor.role", "должность должна быть непустой строкой")
}
let settings = validate-private-settings(private-settings)
let signature = settings.signatures.at(employee-id, default: none)
let signature-image = none
let signature-offset = 0cm
if signature != none {
if type(signature) != dictionary {
fail("private-settings.signatures." + employee-id, "ожидался словарь")
}
let enabled = signature.at("enabled", default: true)
if type(enabled) != bool {
fail(
"private-settings.signatures." + employee-id + ".enabled",
"ожидалось true или false",
)
}
if enabled {
signature-image = path("/.private/executors/" + person.signature-file)
signature-offset = signature.at("offset", default: 0cm)
if type(signature-offset) != length {
fail(
"private-settings.signatures." + employee-id + ".offset",
"ожидалась длина, например 1.25cm",
)
}
}
}
(
resolved-role,
person.name,
signature-image,
signature-offset,
)
}
+105
View File
@@ -0,0 +1,105 @@
// ==========================================
// НУМЕРАЦИЯ, КИРИЛЛИЦА И ФОРМАТЫ СПИСКОВ
// ==========================================
// Глобальный массив кириллических букв для нумерации по ГОСТ
// (обычно исключают Ё, З, Й, О, Ч, Ь, Ы, Ъ)
#let cyrillic_letters = (
"А",
"Б",
"В",
"Г",
"Д",
"Е",
"Ж",
"И",
"К",
"Л",
"М",
"Н",
"П",
"Р",
"С",
"Т",
"У",
"Ф",
"Х",
"Ц",
"Ш",
"Щ",
"Э",
"Ю",
"Я",
)
#let cyrillic_letters_lower = (
"а",
"б",
"в",
"г",
"д",
"е",
"ж",
"и",
"к",
"л",
"м",
"н",
"п",
"р",
"с",
"т",
"у",
"ф",
"х",
"ц",
"ш",
"щ",
"э",
"ю",
"я",
)
// Фильтр для форматирования счетчика в заглавную букву (Приложение А)
#let cyrillic_numbering = (..nums) => {
let n = nums.pos().first()
if n > 0 and n <= cyrillic_letters.len() {
cyrillic_letters.at(n - 1)
} else {
str(n)
}
}
// Формат 1.1. (наследует всех родителей)
#let num_11 = (..nums) => nums.pos().map(str).join(".") + "."
// Формат 1.а. (наследует всех родителей, чередует цифры и кириллицу)
#let num_1a = (..nums) => {
let pos = nums.pos()
let out = ""
for (i, n) in pos.enumerate() {
if calc.rem(i, 2) == 0 { out += str(n) + "." } else {
let letter = if n > 0 and n <= cyrillic_letters_lower.len() {
cyrillic_letters_lower.at(n - 1)
} else {
str(n)
}
out += letter + "."
}
}
out
}
// Формат 1., а. (без наследования родителей, чередует цифры и кириллицу)
#let num_1_a = (..nums) => {
let pos = nums.pos()
let n = pos.last()
if calc.rem(pos.len() - 1, 2) == 0 { str(n) + "." } else {
let letter = if n > 0 and n <= cyrillic_letters_lower.len() {
cyrillic_letters_lower.at(n - 1)
} else {
str(n)
}
letter + "."
}
}
+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,
)
}
}
+6
View File
@@ -0,0 +1,6 @@
// ==========================================
// LEGACY IMPORT PATH: ЕДИНЫЙ ИСТОЧНИК ССЫЛОК
// ==========================================
// Новая реализация живёт в presentation/references.typ. Этот файл оставлен
// только как тонкий import-адаптер для существующих материалов шаблона.
#import "presentation/references.typ": vref, vrefs, eqref, render-bibliographies
+449
View File
@@ -0,0 +1,449 @@
// ==========================================
// ОСНОВНАЯ ФУНКЦИЯ ШАБЛОНА ОТЧЕТА
// ==========================================
#import "presentation/lists.typ": list-numbering
#let report(
// ВАЖНО: позиционный аргумент (body) всегда должен идти ПЕРВЫМ
body,
company_info: "ООО «Скиентия» ИНН 6686148633\nул. Шейнкмана, стр. 9, офис 65\nг. Екатеринбург, 620014, Россия\n+7 (922) 203-24-60 ☏\ninfo@scientia.ru 🖂",
udk: "622.023; 622.271",
director_company: "Директор ООО «Скиентия»",
director_name: "Мусихин А.С.",
director_date: "«23» августа 2025 г.",
sign_image: none,
stamp_image: none,
logo_image: path("../companies/logo.svg"),
is_research: true,
is_intermediate: false,
stage_num: none,
vol_num: none,
title: "МЕСТО ДЛЯ ВВОДА ТЕКСТА",
theme: none,
contract_num: "МЕСТО ДЛЯ ВВОДА ТЕКСТА",
contract_date: "«22» августа 2025",
city: "Екатеринбург",
year: datetime.today().year(),
executors: (),
figure-before: 0.75em,
figure-after: 0.75em,
table-before: 0.75em,
table-after: 0.75em,
figure-caption-before: 0pt,
figure-caption-after: 0pt,
table-caption-before: 0pt,
table-caption-after: 0pt,
show_title_page: true,
show_executors: true,
show_outline: true,
report_mode: "default", // default | draft
watermark: none,
) = {
let resolve-asset-path = resource => {
if type(resource) == str and resource.starts-with("companies/") {
path("../" + resource)
} else {
resource
}
}
let mode = if report_mode == "draft" { "draft" } else { "default" }
let with-signs-and-stamps = mode == "default"
let has-watermark = watermark != none
let watermark-mark = (dx, dy) => place(center + horizon, dx: dx, dy: dy)[
#rotate(35deg)[
#text(
font: "Arial",
size: 40pt,
weight: "bold",
fill: rgb("e8c9c9").transparentize(65%),
)[#watermark]
]
]
let page-foreground = if has-watermark {
[
#watermark-mark(-4.8cm, -8.5cm)
#watermark-mark(0cm, 0cm)
#watermark-mark(4.8cm, 8.5cm)
]
} else {
none
}
let service-title = title => {
v(1.1em, weak: true)
block(sticky: true, width: 100%, {
set par(first-line-indent: 0pt, leading: 0.65em)
set text(weight: "bold", size: 12pt, hyphenate: false)
align(center)[#upper(title)]
})
}
let heading-pre-gap = 1.5em
let heading-to-text-gap = 1.2em
// --- БАЗОВЫЕ НАСТРОЙКИ ---
set document(title: title, author: director_company)
set text(font: "Times New Roman", size: 12pt, lang: "ru")
set page(paper: "a4", margin: (left: 3cm, right: 1.5cm, top: 2cm, bottom: 2cm), foreground: page-foreground)
set par(
justify: true,
first-line-indent: (amount: 1.25cm, all: true),
leading: 0.85em,
// Typst размещает соседние абзацы по отдельному параметру spacing.
// Для этого шрифта 0.85em сохраняет тот же шаг около 18 pt.
spacing: 0.85em,
)
// Табличный текст никогда не наследует красную строку основного текста.
// Правило распространяется и на обычный table, и на corp-table.
show table.cell: set par(first-line-indent: 0pt)
// --- НАСТРОЙКИ СПИСКОВ И ОТСТУПОВ ---
// Слабые отступы схлопываются на границе страницы и между соседними
// структурными блоками. Поэтому здесь нет состояния, которое могло бы
// протечь через таблицу, библиографию или принудительный перенос страницы.
show figure: it => {
let block-before = if it.kind == table { table-before } else { figure-before }
let block-after = if it.kind == table { table-after } else { figure-after }
v(block-before, weak: true)
it
v(block-after, weak: true)
}
show math.equation.where(block: true): it => {
it
}
// Базовые отступы для списков
// Внутри многострочного пункта строки компактнее обычного абзаца, а
// соседние пункты отделены друг от друга заметным интервалом.
set enum(indent: 0.55cm, body-indent: 0.3em, spacing: 0.85em)
set list(indent: 0.7cm, body-indent: 0.5em, spacing: 0.85em, marker: ([--], [--], [--]))
show enum: it => block(above: 0.85em, below: 0.85em, {
set par(leading: 0.55em, spacing: 0pt)
it
})
show list: it => block(above: 0.85em, below: 0.85em, {
set par(leading: 0.55em, spacing: 0pt)
it
})
// ГОСТ-формат по умолчанию использует тот же движок, что и numbered-list.
set enum(full: true, numbering: list-numbering(scheme: "gost"))
// --- НАСТРОЙКИ ЗАГОЛОВКОВ ---
set heading(numbering: "1.1")
show heading: it => {
v(heading-pre-gap, weak: true)
let w = if it.level <= 3 { "bold" } else { "regular" }
set text(weight: w, size: 12pt, hyphenate: false)
// Блок для того, чтобы локальные настройки абзаца применялись ТОЛЬКО к заголовку
[#block(sticky: true, width: 100%, {
set par(first-line-indent: 0pt, leading: 0.65em)
let alignment = if it.level <= 2 { center } else { left }
align(alignment)[
#if it.level == 1 {
if it.numbering != none {
[#counter(heading).display() #upper(it.body)]
} else {
[#upper(it.body)]
}
} else {
if it.numbering != none {
[#counter(heading).display() #it.body ]
} else {
[#it.body]
}
}
]
})#v(heading-to-text-gap, weak: true)]
}
// Сброс счетчиков рисунков/таблиц/формул при новой главе
show heading.where(level: 1): it => {
counter(figure.where(kind: image)).update(0)
counter(figure.where(kind: table)).update(0)
counter(math.equation).update(0)
it
}
// --- НАСТРОЙКИ РИСУНКОВ И ТАБЛИЦ ---
set figure(
numbering: (..nums) => {
let chap = counter(heading).get().first()
if chap > 0 { str(chap) + "." + str(nums.pos().first()) } else { str(nums.pos().first()) }
},
// Задаем полные слова для стандартных ссылок (вместо Рис. / Табл.)
supplement: body => {
if type(body) == content and body.func() == table [Таблица] else [Рисунок]
},
)
// Формат подписей рисунков/таблиц (через тире, как в ГОСТ)
show figure.where(kind: image): set figure.caption(position: bottom)
show figure.where(kind: image): set block(breakable: false)
show figure.where(kind: image): set align(center)
show figure.caption: it => block(
sticky: true,
width: if it.kind == table { 100% } else { auto },
)[
#set text(hyphenate: false)
#set par(
leading: 0.65em,
// Подписи рисунков и таблиц являются отдельными служебными строками,
// поэтому красная строка в них не применяется.
first-line-indent: 0pt,
)
#let cap-before = if it.kind == table { table-caption-before } else { figure-caption-before }
#let cap-after = if it.kind == table { table-caption-after } else { figure-caption-after }
#let prefix = if it.kind == table [Таблица] else [Рисунок]
#let num = it.counter.display(it.numbering)
#v(cap-before)
#align(if it.kind == table { left } else { center })[
#prefix #num -- #it.body
]
#v(cap-after)
]
show figure.where(kind: table): set figure.caption(position: top)
show figure.where(kind: table): set block(breakable: true)
// Формулы: центр по строке, номер справа в формате (глава.номер)
set math.equation(
numbering: (..nums) => {
let chap = counter(heading).get().first()
let num = nums.pos().first()
if chap > 0 { "(" + str(chap) + "." + str(num) + ")" } else { "(" + str(num) + ")" }
},
number-align: end + horizon,
supplement: none,
)
if show_title_page {
// ==========================================
// 1. ТИТУЛЬНЫЙ ЛИСТ
// ==========================================
set page(numbering: none, footer: none, foreground: page-foreground)
// Верхний оранжевый блок с логотипом (прижат к левому краю бумаги)
place(top + left, dx: -2cm, dy: -2cm)[
#rect(fill: rgb("fbb20d"), width: 6.5cm, height: 5.5cm, radius: (bottom: 15pt))[
#place(center + horizon)[
#if logo_image != none {
image(resolve-asset-path(logo_image), width: 4cm)
}
]
]
]
// Нижний оранжевый блок
place(bottom + left, dx: -2cm, dy: 2cm)[
#rect(fill: rgb("fbb20d"), width: 6.5cm, height: 2.5cm, radius: (top: 15pt))
]
// Реквизиты сверху справа
align(right)[
#text(font: "Arial", size: 8pt, fill: luma(100), align(right)[#company_info])
]
v(3cm)
// Блок УДК и Утверждаю
grid(
columns: (1fr, 1fr),
align(left)[
#if udk != none [ УДК: #udk ]
],
align(right)[
#box(width: 8cm)[
#set align(right)
УТВЕРЖДАЮ: \
#director_company \
#v(0.2cm)
#grid(
columns: (4.2cm, 1fr),
column-gutter: 0.2cm,
align: (center + horizon, right + horizon),
box(width: 4.2cm, height: 3.5cm)[
#if with-signs-and-stamps and stamp_image != none {
place(center, dx: -0.4cm)[
#rotate(8deg)[
#image(
resolve-asset-path(stamp_image),
width: 3.5cm,
height: 3.5cm,
fit: "contain",
)
]
]
}
#if with-signs-and-stamps and sign_image != none {
place(center, dx: 0.8cm)[
#rotate(-4deg)[
#image(
resolve-asset-path(sign_image),
width: 3cm,
height: 2.5cm,
fit: "contain",
)
]
]
}
],
align(right)[
#director_name \
#director_date
],
)
]
],
)
v(1.7cm)
// Название
align(center)[
ОТЧЕТ \
#if is_research [
О НАУЧНО-ИССЛЕДОВАТЕЛЬСКОЙ РАБОТЕ
] else [
О РАБОТЕ
]
#v(0cm)
#text(weight: "bold", size: 14pt)[«#upper(title)»] \
#v(0cm)
#if theme != none {
[по теме: \ #theme \ ]
}
#if is_intermediate {
[(промежуточный)]
}
#if stage_num != none or vol_num != none {
v(0.001cm)
}
#if stage_num != none {
[Этап #stage_num \ ]
}
#if vol_num != none {
[Том #vol_num \ ]
}
#if mode == "draft" and has-watermark {
[#text(fill: rgb("cc0000"), weight: "bold")[DRAFT] \ ]
}
]
v(1fr) // Пружина, толкает текст вниз
// Реквизиты договора не должны оставлять служебные подписи при пустых данных.
let has-contract-number = contract_num != none and (
type(contract_num) != str or contract_num.trim() != ""
)
let has-contract-date = contract_date != none and (
type(contract_date) != str or contract_date.trim() != ""
)
if has-contract-number or has-contract-date {
align(right)[
#if has-contract-number [Договор № #contract_num]
#if has-contract-number and has-contract-date { linebreak() }
#if has-contract-date [От #contract_date г.]
]
}
v(1cm)
align(center)[#city #year]
v(1cm)
pagebreak()
}
// ==========================================
// 2. СПИСОК ИСПОЛНИТЕЛЕЙ
// ==========================================
// Встроенное page(numbering: ...) может наследовать контекст выравнивания
// на промежуточном фрагменте разрываемой строки таблицы. Независимый footer
// гарантирует одно и то же положение номера на каждой странице.
set page(
numbering: none,
footer: context {
set text(font: "Times New Roman", size: 12pt)
block(width: 100%)[#align(center)[#counter(page).display("1")]]
},
footer-descent: 30%,
foreground: page-foreground,
)
// Титульный лист не входит в видимую нумерацию. Если титула нет,
// основной текст или первая выбранная служебная страница получает номер 1.
counter(page).update(1)
if show_executors and executors.len() > 0 {
service-title([СПИСОК ИСПОЛНИТЕЛЕЙ])
v(1cm)
table(
columns: (1fr, 3.4cm, auto),
stroke: none,
row-gutter: 2.3em,
align: (left + bottom, center + bottom, left + bottom),
..executors
.map(e => (
e.at(0),
[
#if e.len() > 2 and e.at(2) != none {
let offset = if e.len() > 3 { e.at(3) } else { -0.2cm }
// Картинка располагается относительно линии (bottom ячейки)
if with-signs-and-stamps{place(bottom + center, dy: offset)[
#image(resolve-asset-path(e.at(2)), width: 90%)
]}
}
// Линия является единственным элементом, занимающим место,
// поэтому она всегда строго на уровне нижней границы текста других колонок!
#line(length: 100%, stroke: 0.5pt)
// Текст "Подпись" подвешивается снизу без влияния на высоту строки
#place(top + center, dy: 0.4cm)[
#text(size: 8pt)[Подпись]
]
],
e.at(1),
))
.flatten()
)
pagebreak()
}
// ==========================================
// 3. СОДЕРЖАНИЕ
// ==========================================
if show_outline {
service-title([СОДЕРЖАНИЕ])
// Настройка внешнего вида содержания под стандарт
show outline.entry.where(level: 1): it => {
v(12pt, weak: true)
text(weight: "regular")[#it]
}
// Переопределяем отображение элементов содержания для Приложений (где есть supplement "Приложение")
show outline.entry.where(level: 1): it => {
if it.element.supplement == [Приложение] {
// Поскольку нумерация - функция, мы передаем ее в платформенную функцию numbering
let app_num = numbering(it.element.numbering, ..counter(heading).at(it.element.location()))
// Отменяем глобальный абзацный отступ, чтобы Приложение Б, В не уезжали вправо, и делаем кликабельным
set par(first-line-indent: 0pt)
link(it.element.location())[
#text(weight: "regular")[Приложение #app_num. #it.element.body]
#box(width: 1fr, repeat[.])
#it.page()
]
} else {
text(weight: "regular")[#it]
}
}
outline(title: none, depth: 3, indent: 2em)
pagebreak()
}
// --- ВСТАВКА ОСНОВНОГО ТЕКСТА ---
// В Typst leading — дополнительный промежуток между строками, а не
// коэффициент высоты строки. Для Times New Roman 12 pt значение 0.85em
// даёт шаг базовых линий около 18 pt, то есть полуторный интервал.
set par(leading: 0.85em, spacing: 0.85em)
body
}
+68
View File
@@ -0,0 +1,68 @@
// ==========================================
// SHARED: ЧИСТЫЕ СТРАТЕГИИ НУМЕРАЦИИ
// ==========================================
#let cyrillic_letters = (
"А", "Б", "В", "Г", "Д", "Е", "Ж", "И", "К", "Л", "М", "Н", "П",
"Р", "С", "Т", "У", "Ф", "Х", "Ц", "Ш", "Щ", "Э", "Ю", "Я",
)
#let cyrillic_letters_lower = (
"а", "б", "в", "г", "д", "е", "ж", "и", "к", "л", "м", "н", "п",
"р", "с", "т", "у", "ф", "х", "ц", "ш", "щ", "э", "ю", "я",
)
#let cyrillic_numbering = (..nums) => {
let n = nums.pos().first()
if n > 0 and n <= cyrillic_letters.len() {
cyrillic_letters.at(n - 1)
} else {
str(n)
}
}
#let cyrillic_lower_numbering = (..nums) => {
let n = nums.pos().last()
if n > 0 and n <= cyrillic_letters_lower.len() {
cyrillic_letters_lower.at(n - 1)
} else {
str(n)
}
}
#let num_11 = (..nums) => nums.pos().map(str).join(".") + "."
#let num_1a = (..nums) => {
let pos = nums.pos()
let out = ""
for (i, n) in pos.enumerate() {
if calc.rem(i, 2) == 0 {
out += str(n) + "."
} else {
out += cyrillic_lower_numbering(n) + "."
}
}
out
}
#let num_1_a = (..nums) => {
let pos = nums.pos()
let n = pos.last()
if calc.rem(pos.len() - 1, 2) == 0 {
str(n) + "."
} else {
cyrillic_lower_numbering(n) + "."
}
}
#let attachment_numbering(policy, n) = {
if policy == "arabic" {
str(n)
} else if policy == "cyrillic" {
cyrillic_numbering(n)
} else if policy == "none" {
""
} else {
panic("Scientia / attachment-numbering: неизвестная политика " + repr(policy))
}
}