report.typ 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. // ==========================================
  2. // ОСНОВНАЯ ФУНКЦИЯ ШАБЛОНА ОТЧЕТА
  3. // ==========================================
  4. #import "presentation/lists.typ": list-numbering
  5. #let report(
  6. // ВАЖНО: позиционный аргумент (body) всегда должен идти ПЕРВЫМ
  7. body,
  8. company_info: "",
  9. udk: "622.023; 622.271",
  10. director_company: "",
  11. director_name: "",
  12. director_date: "",
  13. sign_image: none,
  14. stamp_image: none,
  15. logo_image: path("../companies/logo.svg"),
  16. is_research: true,
  17. is_intermediate: false,
  18. stage_num: none,
  19. vol_num: none,
  20. title: "МЕСТО ДЛЯ ВВОДА ТЕКСТА",
  21. theme: none,
  22. contract_num: "МЕСТО ДЛЯ ВВОДА ТЕКСТА",
  23. contract_date: "«22» августа 2025",
  24. city: "Екатеринбург",
  25. year: datetime.today().year(),
  26. executors: (),
  27. figure-before: 0.75em,
  28. figure-after: 0.75em,
  29. table-before: 0.75em,
  30. table-after: 0.75em,
  31. figure-caption-before: 0pt,
  32. figure-caption-after: 0pt,
  33. table-caption-before: 0pt,
  34. table-caption-after: 0pt,
  35. report_mode: "default", // default | draft | paper
  36. watermark: none,
  37. ) = {
  38. let resolve-asset-path = resource => {
  39. if type(resource) == str and resource.starts-with("companies/") {
  40. path("../" + resource)
  41. } else {
  42. resource
  43. }
  44. }
  45. let mode = if report_mode == "draft" or report_mode == "paper" { report_mode } else { "default" }
  46. let with-title-page = mode != "paper"
  47. // Личный проект: содержание выводится и без фирменного титульного листа.
  48. let with-toc = true
  49. let with-signs-and-stamps = mode == "default"
  50. let has-watermark = watermark != none
  51. let watermark-mark = (dx, dy) => place(center + horizon, dx: dx, dy: dy)[
  52. #rotate(35deg)[
  53. #text(
  54. font: "Arial",
  55. size: 40pt,
  56. weight: "bold",
  57. fill: rgb("e8c9c9").transparentize(65%),
  58. )[#watermark]
  59. ]
  60. ]
  61. let page-foreground = if has-watermark {
  62. [
  63. #watermark-mark(-4.8cm, -8.5cm)
  64. #watermark-mark(0cm, 0cm)
  65. #watermark-mark(4.8cm, 8.5cm)
  66. ]
  67. } else {
  68. none
  69. }
  70. let service-title = title => {
  71. v(1.1em, weak: true)
  72. block(sticky: true, width: 100%, {
  73. set par(first-line-indent: 0pt, leading: 0.65em)
  74. set text(weight: "bold", size: 12pt, hyphenate: false)
  75. align(center)[#upper(title)]
  76. })
  77. }
  78. let heading-pre-gap = 1.5em
  79. let heading-to-text-gap = 1.2em
  80. // --- БАЗОВЫЕ НАСТРОЙКИ ---
  81. set document(title: title, author: director_company)
  82. set text(font: "Times New Roman", size: 12pt, lang: "ru")
  83. set page(paper: "a4", margin: (left: 3cm, right: 1.5cm, top: 2cm, bottom: 2cm), foreground: page-foreground)
  84. set par(
  85. justify: true,
  86. first-line-indent: (amount: 1.25cm, all: true),
  87. leading: 0.85em,
  88. // Typst размещает соседние абзацы по отдельному параметру spacing.
  89. // Для этого шрифта 0.85em сохраняет тот же шаг около 18 pt.
  90. spacing: 0.85em,
  91. )
  92. // Табличный текст никогда не наследует красную строку основного текста.
  93. // Правило распространяется и на обычный table, и на corp-table.
  94. show table.cell: set par(first-line-indent: 0pt)
  95. // --- НАСТРОЙКИ СПИСКОВ И ОТСТУПОВ ---
  96. // Слабые отступы схлопываются на границе страницы и между соседними
  97. // структурными блоками. Поэтому здесь нет состояния, которое могло бы
  98. // протечь через таблицу, библиографию или принудительный перенос страницы.
  99. show figure: it => {
  100. let block-before = if it.kind == table { table-before } else { figure-before }
  101. let block-after = if it.kind == table { table-after } else { figure-after }
  102. v(block-before, weak: true)
  103. it
  104. v(block-after, weak: true)
  105. }
  106. show math.equation.where(block: true): it => {
  107. it
  108. }
  109. // Базовые отступы для списков
  110. // Внутри многострочного пункта строки компактнее обычного абзаца, а
  111. // соседние пункты отделены друг от друга заметным интервалом.
  112. set enum(indent: 0.55cm, body-indent: 0.3em, spacing: 0.85em)
  113. set list(indent: 0.7cm, body-indent: 0.5em, spacing: 0.85em, marker: ([--], [--], [--]))
  114. show enum: it => block(above: 0.85em, below: 0.85em, {
  115. set par(leading: 0.55em, spacing: 0pt)
  116. it
  117. })
  118. show list: it => block(above: 0.85em, below: 0.85em, {
  119. set par(leading: 0.55em, spacing: 0pt)
  120. it
  121. })
  122. // ГОСТ-формат по умолчанию использует тот же движок, что и numbered-list.
  123. set enum(full: true, numbering: list-numbering(scheme: "gost"))
  124. // --- НАСТРОЙКИ ЗАГОЛОВКОВ ---
  125. set heading(numbering: "1.1")
  126. show heading: it => {
  127. v(heading-pre-gap, weak: true)
  128. let w = if it.level <= 3 { "bold" } else { "regular" }
  129. set text(weight: w, size: 12pt, hyphenate: false)
  130. // Блок для того, чтобы локальные настройки абзаца применялись ТОЛЬКО к заголовку
  131. [#block(sticky: true, width: 100%, {
  132. set par(first-line-indent: 0pt, leading: 0.65em)
  133. let alignment = if it.level <= 2 { center } else { left }
  134. align(alignment)[
  135. #if it.level == 1 {
  136. if it.numbering != none {
  137. [#counter(heading).display() #upper(it.body)]
  138. } else {
  139. [#upper(it.body)]
  140. }
  141. } else {
  142. if it.numbering != none {
  143. [#counter(heading).display() #it.body ]
  144. } else {
  145. [#it.body]
  146. }
  147. }
  148. ]
  149. })#v(heading-to-text-gap, weak: true)]
  150. }
  151. // Сброс счетчиков рисунков/таблиц/формул при новой главе
  152. show heading.where(level: 1): it => {
  153. counter(figure.where(kind: image)).update(0)
  154. counter(figure.where(kind: table)).update(0)
  155. counter(math.equation).update(0)
  156. it
  157. }
  158. // --- НАСТРОЙКИ РИСУНКОВ И ТАБЛИЦ ---
  159. // Обычные таблицы по ГОСТ: выравнивание содержимого по правому краю с
  160. // вертикальным центрированием, поля ячеек. corp-table передаёт align и
  161. // inset явно и этих значений не наследует.
  162. set table(inset: (x: 3mm, y: 2.2mm), align: right + horizon)
  163. // Заголовочные строки (table.header) обычных таблиц — полужирным.
  164. // Ячейки не знают о принадлежности шапке, поэтому жирнится первая строка
  165. // (y: 0) таблиц внутри figure; вторые строки table.header в главах
  166. // помечаются *...* вручную. Вне figure (таблица исполнителей) не действует.
  167. show figure.where(kind: table): it => {
  168. show table.cell.where(y: 0): set text(weight: "bold")
  169. it
  170. }
  171. set figure(
  172. numbering: (..nums) => {
  173. let chap = counter(heading).get().first()
  174. if chap > 0 { str(chap) + "." + str(nums.pos().first()) } else { str(nums.pos().first()) }
  175. },
  176. // Задаем полные слова для стандартных ссылок (вместо Рис. / Табл.)
  177. supplement: body => {
  178. if type(body) == content and body.func() == table [Таблица] else [Рисунок]
  179. },
  180. )
  181. // Формат подписей рисунков/таблиц (через тире, как в ГОСТ)
  182. show figure.where(kind: image): set figure.caption(position: bottom)
  183. show figure.where(kind: image): set block(breakable: false)
  184. show figure.where(kind: image): set align(center)
  185. show figure.caption: it => block(
  186. sticky: true,
  187. width: if it.kind == table { 100% } else { auto },
  188. )[
  189. #set text(hyphenate: false)
  190. #set par(
  191. leading: 0.65em,
  192. // Подписи рисунков и таблиц являются отдельными служебными строками,
  193. // поэтому красная строка в них не применяется.
  194. first-line-indent: 0pt,
  195. )
  196. #let cap-before = if it.kind == table { table-caption-before } else { figure-caption-before }
  197. #let cap-after = if it.kind == table { table-caption-after } else { figure-caption-after }
  198. #let prefix = if it.kind == table [Таблица] else [Рисунок]
  199. #let num = it.counter.display(it.numbering)
  200. #v(cap-before)
  201. #align(if it.kind == table { left } else { center })[
  202. #prefix #num -- #it.body
  203. ]
  204. #v(cap-after)
  205. ]
  206. show figure.where(kind: table): set figure.caption(position: top)
  207. show figure.where(kind: table): set block(breakable: true)
  208. // Формулы: центр по строке, номер справа в формате (глава.номер)
  209. set math.equation(
  210. numbering: (..nums) => {
  211. let chap = counter(heading).get().first()
  212. let num = nums.pos().first()
  213. if chap > 0 { "(" + str(chap) + "." + str(num) + ")" } else { "(" + str(num) + ")" }
  214. },
  215. number-align: end + horizon,
  216. supplement: none,
  217. )
  218. if with-title-page {
  219. // ==========================================
  220. // 1. ТИТУЛЬНЫЙ ЛИСТ
  221. // ==========================================
  222. set page(numbering: none, footer: none, foreground: page-foreground)
  223. // Верхний оранжевый блок с логотипом (прижат к левому краю бумаги)
  224. place(top + left, dx: -2cm, dy: -2cm)[
  225. #rect(fill: rgb("fbb20d"), width: 6.5cm, height: 5.5cm, radius: (bottom: 15pt))[
  226. #place(center + horizon)[
  227. #if logo_image != none {
  228. image(resolve-asset-path(logo_image), width: 4cm)
  229. }
  230. ]
  231. ]
  232. ]
  233. // Нижний оранжевый блок
  234. place(bottom + left, dx: -2cm, dy: 2cm)[
  235. #rect(fill: rgb("fbb20d"), width: 6.5cm, height: 2.5cm, radius: (top: 15pt))
  236. ]
  237. // Реквизиты сверху справа
  238. align(right)[
  239. #text(font: "Arial", size: 8pt, fill: luma(100), align(right)[#company_info])
  240. ]
  241. v(3cm)
  242. // Блок УДК и Утверждаю
  243. grid(
  244. columns: (1fr, 1fr),
  245. align(left)[
  246. #if udk != none [ УДК: #udk ]
  247. ],
  248. align(right)[
  249. #box(width: 8cm)[
  250. #set align(right)
  251. УТВЕРЖДАЮ: \
  252. #director_company \
  253. #v(0.2cm)
  254. #grid(
  255. columns: (4.2cm, 1fr),
  256. column-gutter: 0.2cm,
  257. align: (center + horizon, right + horizon),
  258. box(width: 4.2cm, height: 3.5cm)[
  259. #if with-signs-and-stamps and stamp_image != none {
  260. place(center, dx: -0.4cm)[
  261. #rotate(8deg)[
  262. #image(
  263. resolve-asset-path(stamp_image),
  264. width: 3.5cm,
  265. height: 3.5cm,
  266. fit: "contain",
  267. )
  268. ]
  269. ]
  270. }
  271. #if with-signs-and-stamps and sign_image != none {
  272. place(center, dx: 0.8cm)[
  273. #rotate(-4deg)[
  274. #image(
  275. resolve-asset-path(sign_image),
  276. width: 3cm,
  277. height: 2.5cm,
  278. fit: "contain",
  279. )
  280. ]
  281. ]
  282. }
  283. ],
  284. align(right)[
  285. #director_name \
  286. #director_date
  287. ],
  288. )
  289. ]
  290. ],
  291. )
  292. v(1.7cm)
  293. // Название
  294. align(center)[
  295. ОТЧЕТ \
  296. #if is_research [
  297. О НАУЧНО-ИССЛЕДОВАТЕЛЬСКОЙ РАБОТЕ
  298. ] else [
  299. О РАБОТЕ
  300. ]
  301. #v(0cm)
  302. #text(weight: "bold", size: 14pt)[«#upper(title)»] \
  303. #v(0cm)
  304. #if theme != none {
  305. [по теме: \ #theme \ ]
  306. }
  307. #if is_intermediate {
  308. [(промежуточный)]
  309. }
  310. #if stage_num != none or vol_num != none {
  311. v(0.001cm)
  312. }
  313. #if stage_num != none {
  314. [Этап #stage_num \ ]
  315. }
  316. #if vol_num != none {
  317. [Том #vol_num \ ]
  318. }
  319. #if mode == "draft" and has-watermark {
  320. [#text(fill: rgb("cc0000"), weight: "bold")[DRAFT] \ ]
  321. }
  322. ]
  323. v(1fr) // Пружина, толкает текст вниз
  324. // Реквизиты договора не должны оставлять служебные подписи при пустых данных.
  325. let has-contract-number = contract_num != none and (
  326. type(contract_num) != str or contract_num.trim() != ""
  327. )
  328. let has-contract-date = contract_date != none and (
  329. type(contract_date) != str or contract_date.trim() != ""
  330. )
  331. if has-contract-number or has-contract-date {
  332. align(right)[
  333. #if has-contract-number [Договор № #contract_num]
  334. #if has-contract-number and has-contract-date { linebreak() }
  335. #if has-contract-date [От #contract_date г.]
  336. ]
  337. }
  338. v(1cm)
  339. align(center)[#city #year]
  340. v(1cm)
  341. pagebreak()
  342. }
  343. // ==========================================
  344. // 2. СПИСОК ИСПОЛНИТЕЛЕЙ
  345. // ==========================================
  346. // Встроенное page(numbering: ...) может наследовать контекст выравнивания
  347. // на промежуточном фрагменте разрываемой строки таблицы. Независимый footer
  348. // гарантирует одно и то же положение номера на каждой странице.
  349. set page(
  350. numbering: none,
  351. footer: context {
  352. set text(font: "Times New Roman", size: 12pt)
  353. block(width: 100%)[#align(center)[#counter(page).display("1")]]
  354. },
  355. footer-descent: 30%,
  356. foreground: page-foreground,
  357. )
  358. if with-title-page {
  359. counter(page).update(1)
  360. if executors.len() > 0 {
  361. service-title([СПИСОК ИСПОЛНИТЕЛЕЙ])
  362. v(1cm)
  363. table(
  364. columns: (1fr, 3.4cm, auto),
  365. stroke: none,
  366. row-gutter: 2.3em,
  367. align: (left + bottom, center + bottom, left + bottom),
  368. ..executors
  369. .map(e => (
  370. e.at(0),
  371. [
  372. #if e.len() > 2 and e.at(2) != none {
  373. let offset = if e.len() > 3 { e.at(3) } else { -0.2cm }
  374. // Картинка располагается относительно линии (bottom ячейки)
  375. if with-signs-and-stamps{place(bottom + center, dy: offset)[
  376. #image(resolve-asset-path(e.at(2)), width: 90%)
  377. ]}
  378. }
  379. // Линия является единственным элементом, занимающим место,
  380. // поэтому она всегда строго на уровне нижней границы текста других колонок!
  381. #line(length: 100%, stroke: 0.5pt)
  382. // Текст "Подпись" подвешивается снизу без влияния на высоту строки
  383. #place(top + center, dy: 0.4cm)[
  384. #text(size: 8pt)[Подпись]
  385. ]
  386. ],
  387. e.at(1),
  388. ))
  389. .flatten()
  390. )
  391. pagebreak()
  392. }
  393. }
  394. // ==========================================
  395. // 3. СОДЕРЖАНИЕ
  396. // ==========================================
  397. if with-toc {
  398. service-title([СОДЕРЖАНИЕ])
  399. // Настройка внешнего вида содержания под стандарт
  400. show outline.entry.where(level: 1): it => {
  401. v(12pt, weak: true)
  402. text(weight: "regular")[#it]
  403. }
  404. // Переопределяем отображение элементов содержания для Приложений (где есть supplement "Приложение")
  405. show outline.entry.where(level: 1): it => {
  406. if it.element.supplement == [Приложение] {
  407. // Поскольку нумерация - функция, мы передаем ее в платформенную функцию numbering
  408. let app_num = numbering(it.element.numbering, ..counter(heading).at(it.element.location()))
  409. // Отменяем глобальный абзацный отступ, чтобы Приложение Б, В не уезжали вправо, и делаем кликабельным
  410. set par(first-line-indent: 0pt)
  411. link(it.element.location())[
  412. #text(weight: "regular")[Приложение #app_num. #it.element.body]
  413. #box(width: 1fr, repeat[.])
  414. #it.page()
  415. ]
  416. } else {
  417. text(weight: "regular")[#it]
  418. }
  419. }
  420. outline(title: none, depth: 3, indent: 2em)
  421. pagebreak()
  422. }
  423. // --- ВСТАВКА ОСНОВНОГО ТЕКСТА ---
  424. // В Typst leading — дополнительный промежуток между строками, а не
  425. // коэффициент высоты строки. Для Times New Roman 12 pt значение 0.85em
  426. // даёт шаг базовых линий около 18 pt, то есть полуторный интервал.
  427. set par(leading: 0.85em, spacing: 0.85em)
  428. body
  429. }