report.typ 17 KB

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