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