report.typ 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  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. 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. // Обычные таблицы по ГОСТ: выравнивание содержимого по правому краю с
  158. // вертикальным центрированием, поля ячеек. corp-table передаёт align и
  159. // inset явно и этих значений не наследует.
  160. set table(inset: (x: 3mm, y: 2.2mm), align: right + horizon)
  161. // Заголовочные строки (table.header) обычных таблиц — полужирным.
  162. // Ячейки не знают о принадлежности шапке, поэтому жирнится первая строка
  163. // (y: 0) таблиц внутри figure; вторые строки table.header в главах
  164. // помечаются *...* вручную. Вне figure (таблица исполнителей) не действует.
  165. show figure.where(kind: table): it => {
  166. show table.cell.where(y: 0): set text(weight: "bold")
  167. it
  168. }
  169. set figure(
  170. numbering: (..nums) => {
  171. let chap = counter(heading).get().first()
  172. if chap > 0 { str(chap) + "." + str(nums.pos().first()) } else { str(nums.pos().first()) }
  173. },
  174. // Задаем полные слова для стандартных ссылок (вместо Рис. / Табл.)
  175. supplement: body => {
  176. if type(body) == content and body.func() == table [Таблица] else [Рисунок]
  177. },
  178. )
  179. // Формат подписей рисунков/таблиц (через тире, как в ГОСТ)
  180. show figure.where(kind: image): set figure.caption(position: bottom)
  181. show figure.where(kind: image): set block(breakable: false)
  182. show figure.where(kind: image): set align(center)
  183. show figure.caption: it => block(
  184. sticky: true,
  185. width: if it.kind == table { 100% } else { auto },
  186. )[
  187. #set text(hyphenate: false)
  188. #set par(
  189. leading: 0.65em,
  190. // Подписи рисунков и таблиц являются отдельными служебными строками,
  191. // поэтому красная строка в них не применяется.
  192. first-line-indent: 0pt,
  193. )
  194. #let cap-before = if it.kind == table { table-caption-before } else { figure-caption-before }
  195. #let cap-after = if it.kind == table { table-caption-after } else { figure-caption-after }
  196. #let prefix = if it.kind == table [Таблица] else [Рисунок]
  197. #let num = it.counter.display(it.numbering)
  198. #v(cap-before)
  199. #align(if it.kind == table { left } else { center })[
  200. #prefix #num -- #it.body
  201. ]
  202. #v(cap-after)
  203. ]
  204. show figure.where(kind: table): set figure.caption(position: top)
  205. show figure.where(kind: table): set block(breakable: true)
  206. // Формулы: центр по строке, номер справа в формате (глава.номер)
  207. set math.equation(
  208. numbering: (..nums) => {
  209. let chap = counter(heading).get().first()
  210. let num = nums.pos().first()
  211. if chap > 0 { "(" + str(chap) + "." + str(num) + ")" } else { "(" + str(num) + ")" }
  212. },
  213. number-align: end + horizon,
  214. supplement: none,
  215. )
  216. if with-title-page {
  217. // ==========================================
  218. // 1. ТИТУЛЬНЫЙ ЛИСТ
  219. // ==========================================
  220. set page(numbering: none, footer: none, foreground: page-foreground)
  221. // Верхний оранжевый блок с логотипом (прижат к левому краю бумаги)
  222. place(top + left, dx: -2cm, dy: -2cm)[
  223. #rect(fill: rgb("fbb20d"), width: 6.5cm, height: 5.5cm, radius: (bottom: 15pt))[
  224. #place(center + horizon)[
  225. #if logo_image != none {
  226. image(resolve-asset-path(logo_image), width: 4cm)
  227. }
  228. ]
  229. ]
  230. ]
  231. // Нижний оранжевый блок
  232. place(bottom + left, dx: -2cm, dy: 2cm)[
  233. #rect(fill: rgb("fbb20d"), width: 6.5cm, height: 2.5cm, radius: (top: 15pt))
  234. ]
  235. // Реквизиты сверху справа
  236. align(right)[
  237. #text(font: "Arial", size: 8pt, fill: luma(100), align(right)[#company_info])
  238. ]
  239. v(3cm)
  240. // Блок УДК и Утверждаю
  241. grid(
  242. columns: (1fr, 1fr),
  243. align(left)[
  244. #if udk != none [ УДК: #udk ]
  245. ],
  246. align(right)[
  247. #box(width: 8cm)[
  248. #set align(right)
  249. УТВЕРЖДАЮ: \
  250. #director_company \
  251. #v(0.2cm)
  252. #grid(
  253. columns: (4.2cm, 1fr),
  254. column-gutter: 0.2cm,
  255. align: (center + horizon, right + horizon),
  256. box(width: 4.2cm, height: 3.5cm)[
  257. #if with-signs-and-stamps and stamp_image != none {
  258. place(center, dx: -0.4cm)[
  259. #rotate(8deg)[
  260. #image(
  261. resolve-asset-path(stamp_image),
  262. width: 3.5cm,
  263. height: 3.5cm,
  264. fit: "contain",
  265. )
  266. ]
  267. ]
  268. }
  269. #if with-signs-and-stamps and sign_image != none {
  270. place(center, dx: 0.8cm)[
  271. #rotate(-4deg)[
  272. #image(
  273. resolve-asset-path(sign_image),
  274. width: 3cm,
  275. height: 2.5cm,
  276. fit: "contain",
  277. )
  278. ]
  279. ]
  280. }
  281. ],
  282. align(right)[
  283. #director_name \
  284. #director_date
  285. ],
  286. )
  287. ]
  288. ],
  289. )
  290. v(1.7cm)
  291. // Название
  292. align(center)[
  293. ОТЧЕТ \
  294. #if is_research [
  295. О НАУЧНО-ИССЛЕДОВАТЕЛЬСКОЙ РАБОТЕ
  296. ] else [
  297. О РАБОТЕ
  298. ]
  299. #v(0cm)
  300. #text(weight: "bold", size: 14pt)[«#upper(title)»] \
  301. #v(0cm)
  302. #if theme != none {
  303. [по теме: \ #theme \ ]
  304. }
  305. #if is_intermediate {
  306. [(промежуточный)]
  307. }
  308. #if stage_num != none or vol_num != none {
  309. v(0.001cm)
  310. }
  311. #if stage_num != none {
  312. [Этап #stage_num \ ]
  313. }
  314. #if vol_num != none {
  315. [Том #vol_num \ ]
  316. }
  317. #if mode == "draft" and has-watermark {
  318. [#text(fill: rgb("cc0000"), weight: "bold")[DRAFT] \ ]
  319. }
  320. ]
  321. v(1fr) // Пружина, толкает текст вниз
  322. // Реквизиты договора не должны оставлять служебные подписи при пустых данных.
  323. let has-contract-number = contract_num != none and (
  324. type(contract_num) != str or contract_num.trim() != ""
  325. )
  326. let has-contract-date = contract_date != none and (
  327. type(contract_date) != str or contract_date.trim() != ""
  328. )
  329. if has-contract-number or has-contract-date {
  330. align(right)[
  331. #if has-contract-number [Договор № #contract_num]
  332. #if has-contract-number and has-contract-date { linebreak() }
  333. #if has-contract-date [От #contract_date г.]
  334. ]
  335. }
  336. v(1cm)
  337. align(center)[#city #year]
  338. v(1cm)
  339. pagebreak()
  340. }
  341. // ==========================================
  342. // 2. СПИСОК ИСПОЛНИТЕЛЕЙ
  343. // ==========================================
  344. // Встроенное page(numbering: ...) может наследовать контекст выравнивания
  345. // на промежуточном фрагменте разрываемой строки таблицы. Независимый footer
  346. // гарантирует одно и то же положение номера на каждой странице.
  347. set page(
  348. numbering: none,
  349. footer: context {
  350. set text(font: "Times New Roman", size: 12pt)
  351. block(width: 100%)[#align(center)[#counter(page).display("1")]]
  352. },
  353. footer-descent: 30%,
  354. foreground: page-foreground,
  355. )
  356. if with-title-page {
  357. counter(page).update(1)
  358. if executors.len() > 0 {
  359. service-title([СПИСОК ИСПОЛНИТЕЛЕЙ])
  360. v(1cm)
  361. table(
  362. columns: (1fr, 3.4cm, auto),
  363. stroke: none,
  364. row-gutter: 2.3em,
  365. align: (left + bottom, center + bottom, left + bottom),
  366. ..executors
  367. .map(e => (
  368. e.at(0),
  369. [
  370. #if e.len() > 2 and e.at(2) != none {
  371. let offset = if e.len() > 3 { e.at(3) } else { -0.2cm }
  372. // Картинка располагается относительно линии (bottom ячейки)
  373. if with-signs-and-stamps{place(bottom + center, dy: offset)[
  374. #image(resolve-asset-path(e.at(2)), width: 90%)
  375. ]}
  376. }
  377. // Линия является единственным элементом, занимающим место,
  378. // поэтому она всегда строго на уровне нижней границы текста других колонок!
  379. #line(length: 100%, stroke: 0.5pt)
  380. // Текст "Подпись" подвешивается снизу без влияния на высоту строки
  381. #place(top + center, dy: 0.4cm)[
  382. #text(size: 8pt)[Подпись]
  383. ]
  384. ],
  385. e.at(1),
  386. ))
  387. .flatten()
  388. )
  389. pagebreak()
  390. }
  391. }
  392. // --- ВСТАВКА ОСНОВНОГО ТЕКСТА ---
  393. // В Typst leading — дополнительный промежуток между строками, а не
  394. // коэффициент высоты строки. Для Times New Roman 12 pt значение 0.85em
  395. // даёт шаг базовых линий около 18 pt, то есть полуторный интервал.
  396. set par(leading: 0.85em, spacing: 0.85em)
  397. body
  398. }
  399. // ==========================================
  400. // СОДЕРЖАНИЕ (вставляется вызывающей стороной — render-report)
  401. // ==========================================
  402. #let report-toc() = {
  403. let service-title = title => {
  404. align(center)[
  405. #text(size: 14pt, weight: "bold")[#title]
  406. #v(1.2em)
  407. ]
  408. }
  409. service-title([СОДЕРЖАНИЕ])
  410. show outline.entry.where(level: 1): it => {
  411. v(12pt, weak: true)
  412. if it.element.numbering == none {
  413. text(weight: "bold")[#it]
  414. } else {
  415. text(weight: "regular")[#it]
  416. }
  417. }
  418. show outline.entry.where(level: 1): it => {
  419. if it.element.supplement == [Приложение] {
  420. let app_num = numbering(it.element.numbering, ..counter(heading).at(it.element.location()))
  421. set par(first-line-indent: 0pt)
  422. link(it.element.location())[
  423. #text(weight: "regular")[Приложение #app_num. #it.element.body]
  424. #box(width: 1fr, repeat[.])
  425. #it.page()
  426. ]
  427. } else {
  428. text(weight: "regular")[#it]
  429. }
  430. }
  431. outline(title: none, depth: 3, indent: 2em)
  432. }