Initial commit

This commit is contained in:
tkachenko committed 2026-10-09 01:45:17 +00:00
commit 41d230d524
163 files changed
+11500

No files matched your search

@@ -0,0 +1,67 @@
#import "/.template/lib/domain/document.typ": render-options, document-profile, validate-profile-contract
#import "/.template/lib/domain/attachments.typ": attachment, attachment-set
#import "/.template/lib/domain/references.typ": bibliography-section, validate-bibliographies
#import "/.template/lib/infrastructure/company-assets.typ": load-company, available-companies
#import "/.template/lib/infrastructure/employees.typ": employee-directory, empty-private-settings, private-company-media, report-executor
#let passthrough(body, ctx) = body
#let run() = {
let options = render-options()
assert.eq(options.mode, "final")
assert.eq(options.at("media-policy"), "placeholder")
let profile = document-profile("unit", passthrough)
assert.eq(validate-profile-contract(profile).id, "unit")
assert.eq(available-companies().len(), 4)
for id in available-companies() {
let loaded = load-company(id)
assert.eq(loaded.kind, "company-profile")
assert(type(loaded.legal.name) == str)
}
let company = load-company("test-company")
assert.eq(company.legal.name, "ООО «Тестовая компания»")
assert.eq(type(company.resources.logo), path)
assert.eq(employee-directory.len(), 19)
let unsigned = report-executor("fedorov")
assert.eq(unsigned.at(1), "Федоров Д.А.")
assert.eq(unsigned.at(2), none)
let private-settings = (
companies: (scientia: (signature: true, stamp: false)),
signatures: (
musikhin: (enabled: true, offset: 1.25cm),
fedorov: (enabled: false, offset: 0.7cm),
),
)
let signed = report-executor(
"musikhin",
role: "Управляющий директор",
private-settings: private-settings,
)
assert.eq(signed.at(0), "Управляющий директор")
assert.eq(type(signed.at(2)), path)
assert.eq(signed.at(3), 1.25cm)
assert.eq(report-executor("fedorov", private-settings: private-settings).at(2), none)
let media = private-company-media(private-settings, "scientia")
assert.eq(type(media.signature), path)
assert.eq(media.stamp, none)
assert.eq(private-company-media(empty-private-settings, "scientia").signature, none)
let item = attachment("a", "Приложение", [Текст])
let items = attachment-set(items: (item,), numbering: "cyrillic")
assert.eq(items.items.len(), 1)
let bibliography = bibliography-section("main", path("/assets/references.bib"))
assert.eq(validate-bibliographies((bibliography,)).len(), 1)
assert.eq(bibliography.at("page-break"), true)
let compact-bibliography = bibliography-section(
"compact",
path("/assets/references.bib"),
page_break: false,
)
assert.eq(compact-bibliography.at("page-break"), false)
"ok"
}
@@ -0,0 +1,12 @@
#import "/.template/lib/numbering.typ": cyrillic_numbering, num_11, num_1a, num_1_a
#let run() = {
assert.eq(cyrillic_numbering(1), "А")
assert.eq(cyrillic_numbering(8), "И")
assert.eq(cyrillic_numbering(25), "Я")
assert.eq(cyrillic_numbering(26), "26")
assert.eq(num_11(1, 2, 3), "1.2.3.")
assert.eq(num_1a(1, 2, 3), "1.б.3.")
assert.eq(num_1_a(1, 2), "б.")
"ok"
}
@@ -0,0 +1,58 @@
#import "/.template/lib/domain/parties.typ": recipient, party, signer
#import "/.template/lib/presentation/profiles/index.typ" as profiles
#import "/.template/lib/shared/numbering.typ": attachment_numbering
#let run() = {
let addressee = recipient(company: "ООО «Адресат»")
let report = profiles.report(
title: "Проверка отчёта",
show_title_page: false,
show_executors: false,
show_outline: true,
)
let report-metadata = (report.validate)(report.metadata)
assert.eq(report.id, "report")
assert.eq(report-metadata.at("show-title-page"), false)
assert.eq(report-metadata.at("show-executors"), false)
assert.eq(report-metadata.at("show-outline"), true)
let letter = profiles.letter(recipient: addressee)
assert.eq(letter.id, "letter")
assert.eq(((letter.validate)(letter.metadata)).recipient.kind, "recipient")
let offer = profiles.commercial_offer(
recipient: addressee,
subject: "Предмет",
amount: "100",
)
assert.eq(offer.id, "commercial-offer")
assert.eq(((offer.validate)(offer.metadata)).terms.amount, "100")
let left = party(
"left",
"Исполнитель",
"ООО «Исполнитель»",
representative: signer("И.И. Исполнитель", "Директор"),
)
let right = party(
"right",
"Заказчик",
"ООО «Заказчик»",
representative: signer("З.З. Заказчик", "Директор"),
)
let contract = profiles.contract(
number: "1",
parties: (left, right),
sections: (
profiles.contract_section("subject", "Предмет", [Текст раздела]),
),
)
assert.eq(contract.id, "contract")
assert.eq(((contract.validate)(contract.metadata)).sections.len(), 1)
assert.eq(attachment_numbering("arabic", 2), "2")
assert.eq(attachment_numbering("cyrillic", 2), "Б")
assert.eq(attachment_numbering("none", 2), "")
"ok"
}
@@ -0,0 +1,49 @@
#import "/.template/lib/api.typ": document, recipient, attachment, attachment-set
#import "/.template/lib/presentation/profiles/commercial-offer.typ": commercial-offer-profile
#let addressee = recipient(
company: "АО «Заказчик»",
title: "Руководителю службы автоматизации",
name: "С.С. Заказчикову",
)
#let appendices = attachment-set(items: (
attachment(
"scope",
"Техническое задание",
[
Поставка, настройка и ввод в эксплуатацию тестового комплекса.
],
),
))
#show: document.with(
company: "test-company",
profile: commercial-offer-profile(
recipient: addressee,
subject: "Поставка и внедрение тестового комплекса",
amount: "1 250 000",
currency: "руб.",
tax_note: "включая НДС 20 %",
delivery_term: "45 рабочих дней",
validity: "30 календарных дней",
payment_terms: "30 % аванс, 70 % после приёмки",
date: "26.08.2026",
reference: "ТКП-001",
note: "Контактное лицо: А.А. Автор",
attachments: appendices,
),
)
Уважаемый Сергей Сергеевич!
Предлагаем выполнить комплекс работ по поставке и внедрению оборудования.
== Состав работ
- обследование объекта;
- поставка оборудования;
- монтаж и пусконаладка;
- обучение персонала заказчика.
Гарантийный срок составляет 12 месяцев с даты подписания акта приёмки.
+85
View File
@@ -0,0 +1,85 @@
#import "/.template/lib/api.typ": document, party, signer, attachment, attachment-set, load-company
#import "/.template/lib/presentation/profiles/contract.typ": contract-profile, contract-section
#let company = load-company("test-company")
#let contractor = party(
"contractor",
"Исполнитель",
company.legal.name,
legal: company.legal,
contacts: company.contacts,
banking: company.banking,
representative: signer(
company.director.name,
company.director.title,
basis: "Устава",
signature: company.resources.signature,
stamp: company.resources.stamp,
),
)
#let customer = party(
"customer",
"Заказчик",
"ООО «Заказчик»",
legal: (
inn: "1111111111",
kpp: "111111111",
address: "г. Пример, ул. Договорная, 2",
),
contacts: (email: "customer@example.invalid"),
banking: (
bank: "Банк заказчика",
account: "11111111111111111111",
bik: "111111111",
),
representative: signer(
"П.П. Заказчиков",
"Генеральный директор",
basis: "Устава",
),
)
#let sections = (
contract-section(
"subject",
"Предмет договора",
[Исполнитель обязуется выполнить работы, а Заказчик — принять и оплатить их на условиях настоящего договора.],
),
contract-section(
"price",
"Цена и порядок расчётов",
[Цена договора составляет 1 250 000 рублей, включая НДС 20 %.],
),
contract-section(
"liability",
"Ответственность сторон",
[Стороны несут ответственность в соответствии с законодательством Российской Федерации.],
),
)
#let appendices = attachment-set(items: (
attachment(
"specification",
"Спецификация",
[
Перечень работ и оборудования согласуется сторонами в настоящем приложении.
],
),
))
#show: document.with(
company: "test-company",
profile: contract-profile(
number: "Д-001/2026",
date: "26 августа 2026 г.",
place: "г. Пример",
title: "Договор оказания услуг",
parties: (contractor, customer),
sections: sections,
attachments: appendices,
),
)
Настоящий договор вступает в силу с момента подписания обеими сторонами.
@@ -0,0 +1,26 @@
#import "/.template/lib/api.typ": document, document-profile
#let smoke-renderer(body, ctx) = [
#set text(font: "Arial", size: 11pt, lang: "ru")
#set page(paper: "a4")
#text(weight: "bold")[DDD CORE SMOKE]
Компания: #ctx.company.legal.name
Профиль: #ctx.at("profile-id")
#body
]
#let smoke-profile = document-profile(
"smoke",
smoke-renderer,
metadata: (:),
)
#show: document.with(
company: "test-company",
profile: smoke-profile,
)
Изолированное ядро успешно собрано.
@@ -0,0 +1,7 @@
#import "/.template/lib/index.typ": document
#show: document.with(company: "test-company")
#heading(numbering: none)[ПРОВЕРКА ПРОФИЛЯ ПО УМОЛЧАНИЮ]
Корневой фасад формирует отчёт без явной передачи profile.
@@ -0,0 +1,15 @@
#import "/.template/lib/index.typ": document, profiles, recipient, info-block
#show: document.with(
company: "test-company",
profile: profiles.letter(
recipient: recipient(company: "ООО «Проверка фасада»"),
title: "Публичный фасад",
show_stamp: false,
),
options: (mode: "clean-copy", media-policy: "hide"),
)
#info-block(title: [ПРОВЕРКА КОМПОНЕНТА])[
Корневой файл экспортирует единый фасад и namespace профилей.
]
+38
View File
@@ -0,0 +1,38 @@
#import "/.template/lib/api.typ": document, recipient, attachment, attachment-set
#import "/.template/lib/presentation/profiles/letter.typ": letter-profile
#let addressee = recipient(
company: "ООО «Получатель»",
title: "Генеральному директору",
name: "П.П. Получателю",
address: "г. Пример, ул. Адресная, 10",
)
#let appendices = attachment-set(items: (
attachment(
"specification",
"Краткая спецификация",
[
Состав приложения задаётся пользователем независимо от профиля письма.
],
),
))
#show: document.with(
company: "test-company",
profile: letter-profile(
recipient: addressee,
date: "26.08.2026",
reference: "Л-001",
title: "О направлении материалов",
note: "Исполнитель: А.А. Автор, +7 (000) 000-00-01",
attachments: appendices,
render_attachments: true,
),
)
Уважаемый Пётр Петрович!
Направляем материалы для рассмотрения. Профиль письма использует единый источник данных компании и самостоятельно формирует шапку, подвал, список приложений и блок подписи.
Просим подтвердить получение документов.
@@ -0,0 +1,90 @@
#import "/.template/lib/index.typ": numbered-list
#set page("a4", margin: 1cm)
#set text(size: 9pt)
#set par(first-line-indent: 0pt, leading: 0.4em)
= SCIENTIA LIST COUNTERS
#let compact-list(style, body) = numbered-list(
levels: (style,),
suffixes: ".",
line-leading: 0.4em,
item-spacing: 0pt,
body,
)
== Арабские числа
#compact-list("1")[
+ Арабские: первый
+ Арабские: второй
+ Арабские: третий
]
== Кириллица
#grid(
columns: (1fr, 1fr),
gutter: 1cm,
[
#compact-list("А")[
+ Кириллица верхняя: первый
+ Кириллица верхняя: второй
+ Кириллица верхняя: третий
]
],
[
#compact-list("а")[
+ Кириллица нижняя: первый
+ Кириллица нижняя: второй
+ Кириллица нижняя: третий
]
],
)
== Латиница
#grid(
columns: (1fr, 1fr),
gutter: 1cm,
[
#compact-list("A")[
+ Латиница верхняя: первый
+ Латиница верхняя: второй
+ Латиница верхняя: третий
]
],
[
#compact-list("a")[
+ Латиница нижняя: первый
+ Латиница нижняя: второй
+ Латиница нижняя: третий
]
],
)
== Ведущий ноль
#compact-list("01")[
+ Ведущий ноль: первый
+ Ведущий ноль: второй
+ Ведущий ноль: третий
]
== Вложенные счётчики
#numbered-list(
levels: ("1", "а", "A"),
full: true,
separators: ".",
suffixes: ".",
line-leading: 0.4em,
item-spacing: 0pt,
)[
+ Корневой уровень
+ Второй уровень: первый
+ Второй уровень: второй
+ Третий уровень: первый
+ Третий уровень: второй
]
+60
View File
@@ -0,0 +1,60 @@
#import "/.template/lib/index.typ": document, profiles, recipient, party, signer, load-company
#let profile-id = sys.inputs.at("profile", default: "letter")
#let mode = sys.inputs.at("mode", default: "draft")
#let company-id = sys.inputs.at("company", default: "test-company")
#let company = load-company(company-id)
#let addressee = recipient(company: "ООО «Проверка режима»")
#let left = party(
"left",
"Исполнитель",
company.legal.name,
legal: company.legal,
contacts: company.contacts,
banking: company.banking,
representative: signer(
company.director.name,
company.director.title,
signature: company.resources.signature,
stamp: company.resources.stamp,
),
)
#let right = party(
"right",
"Заказчик",
"ООО «Проверка режима»",
representative: signer("П.П. Проверяющий", "Директор"),
)
#let selected-profile = if profile-id == "report" {
profiles.report(title: "Проверка режима выпуска", year: 2026)
} else if profile-id == "letter" {
profiles.letter(recipient: addressee, title: "Проверка режима выпуска")
} else if profile-id == "commercial-offer" {
profiles.commercial_offer(
recipient: addressee,
subject: "Проверка режима выпуска",
amount: "100 000",
currency: "руб.",
)
} else if profile-id == "contract" {
profiles.contract(
number: "MODE-1",
date: "26.08.2026",
parties: (left, right),
sections: (
profiles.contract_section("subject", "Предмет договора", [Проверка режима выпуска.]),
),
)
} else {
panic("Неизвестный тестовый profile: " + profile-id)
}
#show: document.with(
company: company,
profile: selected-profile,
options: (mode: mode, watermark: if mode == "draft" { "ЧЕРНОВИК" } else { none }),
)
Документ собран в режиме #mode для профиля #profile-id.
@@ -0,0 +1,5 @@
#import "/.template/lib/index.typ": document
#show: document.with(company: "unknown-company")
Этот текст не должен попасть в PDF.
@@ -0,0 +1,13 @@
#import "/.template/lib/index.typ": document, profiles, party
#let duplicate = (
party("same", "Исполнитель", "ООО «Первый»"),
party("same", "Заказчик", "ООО «Второй»"),
)
#show: document.with(
company: "test-company",
profile: profiles.contract(number: "1", parties: duplicate),
)
Этот текст не должен попасть в PDF.
@@ -0,0 +1,8 @@
#import "/.template/lib/index.typ": document, profiles
#show: document.with(
company: "test-company",
profile: profiles.letter(),
)
Этот текст не должен попасть в PDF.
@@ -0,0 +1,5 @@
#import "/.template/lib/index.typ": numbered-list
#numbered-list(scheme: "неизвестная-схема")[
+ Тестовый пункт
]
@@ -0,0 +1,11 @@
#import "/.template/lib/index.typ": document, profiles, recipient
#show: document.with(
company: "test-company",
profile: profiles.commercial_offer(
recipient: recipient(company: "ООО «Адресат»"),
subject: "Предложение без стоимости",
),
)
Этот текст не должен попасть в PDF.
@@ -0,0 +1,3 @@
#import "/.template/lib/index.typ": vref
#vref(<missing-reference>, grammatical-case: "мест")
@@ -0,0 +1,5 @@
#import "/.template/lib/index.typ": vrefs
#vrefs((<valid-label>, "not-a-label"))
#figure(rect(width: 1cm, height: 1cm), caption: [Элемент]) <valid-label>
@@ -0,0 +1,11 @@
#import "/.template/lib/index.typ": document, profiles
#show: document.with(
company: "test-company",
profile: profiles.report(
title: "Ошибка пути приложения",
appendices: ("appendix.typ",),
),
)
Проверка понятной ошибки для строкового пути.
+39
View File
@@ -0,0 +1,39 @@
#import "/.template/lib/index.typ": document, profiles, recipient, company-profile
#let policy = sys.inputs.at("policy", default: "placeholder")
#let company = company-profile(
"no-media",
(
name: "ООО «Компания без встроенных графических ресурсов»",
short-name: "Без ресурсов",
jurisdiction: "RU",
inn: "0000000000",
),
contacts: (
email: "no-media@example.invalid",
phone: "+7 (000) 000-00-00",
address: "г. Пример, очень длинный адрес для проверки переноса строк в деловом документе, дом 100, офис 200",
),
banking: (:),
brand: (color: rgb("e39f49")),
director: (title: "Генеральный директор", name: "И.И. Подписант"),
resources: (logo: none, signature: none, stamp: none),
)
#show: document.with(
company: company,
profile: profiles.letter(
recipient: recipient(
company: "Акционерное общество «Организация с длинным наименованием для проверки устойчивости вёрстки»",
title: "Заместителю генерального директора по техническим и коммерческим вопросам",
name: "П.П. Получателю",
address: "г. Пример, проспект Испытательный, дом 123, строение 45",
),
date: "26.08.2026",
reference: "NO-MEDIA-001",
title: "Проверка формирования документа без логотипа, подписи и печати",
),
options: (mode: "final", media-policy: policy),
)
Документ без графических ресурсов собран с политикой #policy.
@@ -0,0 +1,49 @@
#import "/.template/lib/api.typ": load-company, recipient, signer
#import "/.template/lib/presentation/foundation.typ": apply-foundation, render-media-slot
#import "/.template/lib/presentation/components.typ": corp_table, company-footer, letter-header, signature-block
#let company = load-company("test-company")
#let addressee = recipient(
company: "ООО «Получатель»",
title: "Руководителю проекта",
name: "П.П. Примерову",
)
#let director = signer(
company.director.name,
company.director.title,
signature: company.resources.signature,
stamp: company.resources.stamp,
)
#let ctx = (
company: company,
options: (watermark: none),
)
#show: apply-foundation.with(
ctx: ctx,
page-options: (margin: (x: 2cm, y: 1.5cm)),
)
#letter-header(
company,
addressee,
date: "26.08.2026",
reference: "TEST-1",
title: "Проверка компонентов",
)
#v(0.8cm)
Общая presentation-основа не зависит от конкретного профиля документа.
#v(0.8cm)
#corp_table(
columns: 2,
[Компонент], [Состояние],
[Таблица], [Работает],
[Media slot], [#render-media-slot(none, width: 3cm, height: 1cm)],
)
#v(1cm)
#signature-block(director, company: company)
#place(bottom + left, dy: 0.5cm, company-footer(company))
@@ -0,0 +1,49 @@
#import "/.template/lib/index.typ": vref, vrefs
#set page(paper: "a4", margin: 15mm)
#set text(lang: "ru", size: 9pt)
#set heading(numbering: "1.")
#set figure(numbering: "1")
#figure(
rect(width: 12mm, height: 6mm),
caption: [Первый рисунок],
) <case-figure-a>
#figure(
rect(width: 12mm, height: 6mm),
caption: [Второй рисунок],
) <case-figure-b>
#figure(
table(columns: 1, [Значение]),
caption: [Тестовая таблица],
) <case-table>
#math.equation(block: true, numbering: "(1)")[$x = 1$] <case-equation>
= Тестовый раздел <case-section>
Предложный по умолчанию: на #vref(<case-figure-a>).
Именительный коротко: #vref(<case-figure-a>, "и").
Родительный коротко: без #vref(<case-figure-a>, "р").
Дательный коротко: к #vref(<case-figure-a>, "д").
Винительный коротко: вижу #vref(<case-figure-a>, "в").
Творительный коротко: перед #vref(<case-figure-a>, "т").
Предложный коротко: на #vref(<case-figure-a>, "п").
Явная строчная форма: на #vref(<case-figure-a>, capitalized: false).
Полное название: на #vref(<case-table>, "предложный").
Формула: согласно #vref(<case-equation>, "дательный").
Раздел: в #vref(<case-section>).
Группа по умолчанию: на #vrefs((<case-figure-a>, <case-figure-b>)).
@@ -0,0 +1,8 @@
= Исходные данные <test-appendix-source>
Первое приложение подключено отдельным файлом.
#figure(
rect(width: 4cm, height: 1.5cm, fill: luma(235), stroke: 0.5pt),
caption: [Схема первого приложения],
) <test-appendix-figure>
@@ -0,0 +1,12 @@
= Проверочные расчёты <test-appendix-calculations>
Второе приложение автоматически продолжает кириллическую нумерацию.
#figure(
table(
columns: (1fr, 1fr),
[Параметр], [Значение],
[Коэффициент], [1,3],
),
caption: [Таблица второго приложения],
) <test-appendix-table>
@@ -0,0 +1,38 @@
#import "/.template/lib/index.typ": document, profiles, vref, vrefs
#show: document.with(
company: "test-company",
profile: profiles.report(
title: "Проверка файловых приложений",
year: 2026,
executors: (),
appendices: (
path("appendices/01-source-data.typ"),
path("appendices/02-calculations.typ"),
),
appendix_numbering: "cyrillic",
appendix_start: 1,
show_title_page: false,
show_executors: false,
show_outline: true,
),
options: (mode: "clean-copy", media-policy: "hide"),
)
= ОСНОВНОЙ ТЕКСТ
Стандартная ссылка: @test-appendix-source.
Именительный: #vref(<test-appendix-source>, "и").
Родительный: без #vref(<test-appendix-source>, "р").
Дательный: к #vref(<test-appendix-source>, "д").
Винительный: вижу #vref(<test-appendix-source>, "в").
Творительный: перед #vref(<test-appendix-source>, "т").
Предложный по умолчанию: в #vref(<test-appendix-source>).
Группа приложений: в #vrefs((<test-appendix-source>, <test-appendix-calculations>)).
@@ -0,0 +1,71 @@
#import "/.template/lib/report.typ": report
#import "/.template/lib/company.typ": company-defaults
#import "/.template/lib/components.typ": corp_table, formula
#import "/.template/lib/appendices.typ": make_appendices
#import "/.template/lib/references.typ": vref, eqref
#show: report.with(
..company-defaults(company-id: "test-company"),
udk: "000.000",
director_date: "«26» августа 2026 г.",
is_research: true,
title: "Тестовый отчёт",
theme: "Регрессионная проверка шаблона",
is_intermediate: true,
stage_num: 1,
vol_num: 1,
contract_num: "TEST-001",
contract_date: "«26» августа 2026",
year: 2026,
executors: (
("Ответственный исполнитель", "Тестов И.И.", path("/.template/companies/test-company/sign.svg"), 0.5cm),
("Инженер", "Примеров П.П.", path("/.template/companies/test-company/sign.svg"), 0.5cm),
),
)
#heading(numbering: none)[ВВЕДЕНИЕ]
Этот документ фиксирует исходное поведение шаблона перед архитектурным рефакторингом.
= ОСНОВНОЙ РАЗДЕЛ
Сложные элементы должны сохранять нумерацию, подписи и ссылки.
#figure(
image("/.template/companies/test-company/logo.svg", width: 35%),
caption: [Синтетический логотип для визуального теста],
) <baseline-figure>
Ссылка на рисунок: #vref(<baseline-figure>, grammatical-case: "вин").
#figure(
corp_table(
columns: (1fr, 1fr, 1fr),
table.cell(rowspan: 2, align: center + horizon)[Параметр],
table.cell(colspan: 2, align: center + horizon)[Значения],
[Минимум], [Максимум],
[Тестовая строка], [10], [20],
[Вторая строка], [30], [40],
),
caption: [Таблица с многострочной шапкой],
) <baseline-table>
Ссылка на таблицу: #vref(<baseline-table>, grammatical-case: "предл").
#formula($a^2 + b^2 = c^2$) <baseline-equation>
Ссылка на формулу: #eqref(<baseline-equation>).
#pagebreak()
#heading(numbering: none)[ЗАКЛЮЧЕНИЕ]
Компиляция и визуальное сравнение подтверждают стабильность ключевых элементов.
#pagebreak()
#heading(numbering: none)[СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ]
#bibliography("/assets/references.bib", title: none, style: "gost-r-705-2008-numeric")
#show: make_appendices
= ТЕСТОВОЕ ПРИЛОЖЕНИЕ
Содержимое приложения используется для проверки кириллической нумерации.
@@ -0,0 +1,3 @@
= Тестовое приложение <new-test-appendix>
Приложение создано как самостоятельный Typst-файл.
@@ -0,0 +1,87 @@
#import "/.template/lib/api.typ": document, bibliography-section
#import "/.template/lib/presentation/profiles/report.typ": report-profile
#import "/.template/lib/presentation/components.typ": corp_table, formula
#import "/.template/lib/presentation/references.typ": vref, vrefs, eqref
#let appendices = (
path("appendix.typ"),
)
#let bibliographies = (
bibliography-section(
"normative",
path("normative.bib"),
title: [НОРМАТИВНЫЕ ИСТОЧНИКИ],
group: "report-sources",
),
bibliography-section(
"science",
path("science.bib"),
title: [НАУЧНЫЕ ИСТОЧНИКИ],
group: "report-sources",
),
)
#show: document.with(
company: "test-company",
profile: report-profile(
title: "Новый модульный отчёт",
theme: "Проверка DDD-профиля",
udk: "000.000",
director_date: "«26» августа 2026 г.",
is_research: true,
is_intermediate: true,
stage_number: 1,
volume_number: 1,
contract_number: "DDD-001",
contract_date: "«26» августа 2026",
city: "Пример",
year: 2026,
executors: (
("Ответственный исполнитель", "Тестов И.И.", path("/.template/companies/test-company/sign.svg"), 0.5cm),
),
appendices: appendices,
appendix_numbering: "cyrillic",
appendix_start: 1,
bibliographies: bibliographies,
),
)
#heading(numbering: none)[ВВЕДЕНИЕ]
Профиль отчёта использует нормативный источник @normative-test и научную публикацию @science-test.
Тестовое содержимое вынесено в #vref(<new-test-appendix>).
= ПРОВЕРКА ССЫЛОК
#figure(
rect(width: 3cm, height: 1.5cm, fill: rgb("fbb20d")),
caption: [Первый тестовый рисунок],
) <new-figure-a>
#figure(
circle(radius: 0.7cm, fill: rgb("e39f49")),
caption: [Второй тестовый рисунок],
) <new-figure-b>
Одна ссылка: #vref(<new-figure-a>, grammatical-case: "предл").
Несколько ссылок: #vrefs((<new-figure-a>, <new-figure-b>), grammatical-case: "вин").
#figure(
corp_table(
columns: 2,
[Параметр], [Значение],
[Архитектура], [Изолированная],
),
caption: [Проверочная таблица],
)
#formula($x^2 + y^2 = z^2$) <new-equation>
Формула: #eqref(<new-equation>).
#heading(numbering: none)[ЗАКЛЮЧЕНИЕ]
Новый отчёт собирается через `document` и `report-profile`.
@@ -0,0 +1,6 @@
@book{normative-test,
title={Синтетический нормативный источник},
author={{Тестовый регулятор}},
year={2026},
publisher={Тестовое издательство}
}
@@ -0,0 +1,8 @@
@article{science-test,
title={Синтетическое исследование устойчивости шаблонов},
author={Тестов, И. И.},
year={2026},
journal={Журнал тестовых данных},
volume={1},
pages={1--10}
}
@@ -0,0 +1,31 @@
#import "/.template/lib/index.typ": document, profiles, load-company
#let enabled(name) = sys.inputs.at(name, default: "true") == "true"
#let company = load-company("test-company", signature: none, stamp: none)
#let executors = (
("Ответственный исполнитель", "Тестов И.И.", none, 0cm),
)
#show: document.with(
company: company,
profile: profiles.report(
title: "Контрольный отчёт",
theme: "Независимые служебные страницы",
city: "Екатеринбург",
year: 2026,
executors: executors,
show_title_page: enabled("title"),
show_executors: enabled("executors"),
show_outline: enabled("outline"),
),
options: (
mode: "clean-copy",
watermark: none,
media-policy: "hide",
diagnostics: true,
),
)
= ОСНОВНОЙ ТЕКСТ
Основной текст контрольного отчёта начинается без пустой промежуточной страницы.
@@ -0,0 +1,136 @@
#import "/.template/lib/index.typ": document, profiles, corp-table, formula, vref, vrefs, eqref
#let stress-rows = range(1, 73).map(index => (
[#index],
[
#if index == 3 {
[Составной параметр:
- первый вложенный пункт;
- второй вложенный пункт с длинным пояснением внутри ячейки.]
} else {
[Параметр #index с достаточно длинным наименованием для проверки переноса строк]
}
],
[
#if index == 4 {
[Значение #index. #link("https://example.invalid/table-cell")[Ссылка внутри ячейки] сохраняет кликабельность и не меняет отступы.]
} else {
[Значение #index. Текст ячейки переносится, но сохраняет одинаковые внутренние поля и межстрочный интервал.]
}
],
)).flatten()
#show: document.with(
company: "test-company",
profile: profiles.report(
title: "Стресс-тест типографики",
theme: "Таблицы, списки, формулы и грамматические ссылки",
year: 2026,
executors: (),
),
)
= КОНТЕКСТНЫЕ ИНТЕРВАЛЫ
== Заголовок перед абзацем
Обычный абзац после заголовка должен иметь устойчивую красную строку, нормативный межстрочный интервал и не зависеть от элемента, который находился перед заголовком.
== Заголовок перед маркированным списком
- Первый уровень маркированного списка;
- второй уровень с длинным текстом, который переносится на новую строку без смещения маркера;
- третий уровень.
- Возврат на первый уровень.
== Заголовок перед нумерованным списком
+ Основной пункт
+ Второй уровень
+ Третий уровень
+ Следующий основной пункт
== Соседние заголовки
=== Заголовок третьего уровня
==== Заголовок четвёртого уровня
Текст после цепочки заголовков.
= ССЫЛКИ С ПАДЕЖАМИ <stress-section>
#figure(
rect(width: 3cm, height: 1.3cm, fill: rgb("fbb20d")),
caption: [Первый контрольный рисунок],
) <stress-figure-a>
#figure(
circle(radius: 0.65cm, fill: rgb("e39f49")),
caption: [Второй контрольный рисунок],
) <stress-figure-b>
#formula($a^2 + b^2 = c^2$) <stress-equation>
Именительный: #vref(<stress-figure-a>, grammatical-case: "имен").
Родительный: без #vref(<stress-figure-a>, grammatical-case: "род").
Дательный: к #vref(<stress-figure-a>, grammatical-case: "дат").
Винительный: см. #vref(<stress-figure-a>, grammatical-case: "вин").
Творительный: перед #vref(<stress-figure-a>, grammatical-case: "тв").
Предложный: на #vref(<stress-figure-a>, grammatical-case: "предл").
Групповая ссылка: на #vrefs(
(<stress-figure-a>, <stress-figure-b>),
grammatical-case: "предл",
).
Формула в дательном падеже: к #vref(<stress-equation>, grammatical-case: "дат"); короткая ссылка #eqref(<stress-equation>).
Ссылка на раздел: в #vref(<stress-section>, grammatical-case: "предл").
Внешняя ссылка: #link("https://example.invalid/specification")[контрольная спецификация].
= МНОГОСТРАНИЧНАЯ ТАБЛИЦА
#figure(
corp-table(
columns: (1.2cm, 1.7fr, 2.3fr),
header: (
table.cell(rowspan: 2, align: center + horizon)[№],
table.cell(colspan: 2, align: center + horizon)[Контрольные данные],
[Параметр],
[Описание и значение],
),
body: stress-rows,
repeat_header: true,
continuation: true,
continuation_text: "Продолжение таблицы",
body-leading: 0.65em,
body-inset: (x: 4pt, y: 3pt),
justify: false,
hyphenate: false,
),
caption: [Таблица, переходящая через несколько страниц],
) <stress-table>
= ПРОВЕРКА ПОСЛЕ ТАБЛИЦЫ
Текст после таблицы не должен прилипать к последней строке. Ссылка на #vref(<stress-table>, grammatical-case: "вин") остаётся рабочей.
#pagebreak()
= ВЫСОКАЯ СТРОКА ТАБЛИЦЫ
#let tall-cell = range(1, 66).map(index => [Строка высокой ячейки #index. Содержимое одной строки таблицы продолжается без потери границ.]).join(linebreak())
#figure(
corp-table(
columns: (3cm, 1fr),
header: ([Идентификатор], [Высокая ячейка]),
body: ([ROW-ONE], tall-cell),
row_breakable: true,
page_break: "fit",
repeat_header: true,
continuation: true,
continuation_text: "Продолжение высокой таблицы",
justify: false,
),
caption: [Одна высокая строка, переходящая через страницы],
) <tall-row-table>
+542
View File
@@ -0,0 +1,542 @@
{
"version": 1,
"typst_min": "0.15.1",
"creation_timestamp": 1787702400,
"dpi": 110,
"visual_threshold": 0.001,
"unit_tests": [
{
"id": "legacy-numbering",
"module": ".template/development/tests/domain/legacy-numbering.typ"
},
{
"id": "core-domain",
"module": ".template/development/tests/domain/core.typ"
},
{
"id": "profile-domain",
"module": ".template/development/tests/domain/profiles.typ"
}
],
"fixtures": [
{
"id": "core-smoke",
"entry": ".template/development/tests/fixtures/core-smoke/main.typ",
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": [
"DDD CORE SMOKE",
"ООО «Тестовая компания»",
"Изолированное ядро успешно собрано"
],
"snapshot_pages": []
},
{
"id": "facade-smoke",
"entry": ".template/development/tests/fixtures/facade-smoke/main.typ",
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": [
"ПУБЛИЧНЫЙ ФАСАД",
"Корневой файл экспортирует единый фасад"
],
"snapshot_pages": []
},
{
"id": "default-report",
"entry": ".template/development/tests/fixtures/default-report/main.typ",
"expected_pages": 3,
"expected_page_size": "A4",
"required_text": [
"ПРОВЕРКА ПРОФИЛЯ ПО УМОЛЧАНИЮ",
"Корневой фасад формирует отчёт"
],
"snapshot_pages": []
},
{
"id": "report-service-pages-full",
"entry": ".template/development/tests/fixtures/report-service-pages/main.typ",
"inputs": {"title": "true", "executors": "true", "outline": "true"},
"expected_pages": 4,
"expected_page_size": "A4",
"required_text": [
"КОНТРОЛЬНЫЙ ОТЧЁТ",
"СПИСОК ИСПОЛНИТЕЛЕЙ",
"СОДЕРЖАНИЕ",
"ОСНОВНОЙ ТЕКСТ"
],
"snapshot_pages": []
},
{
"id": "report-service-pages-outline-only",
"entry": ".template/development/tests/fixtures/report-service-pages/main.typ",
"inputs": {"title": "false", "executors": "false", "outline": "true"},
"expected_pages": 2,
"expected_page_size": "A4",
"required_text": ["СОДЕРЖАНИЕ", "ОСНОВНОЙ ТЕКСТ"],
"forbidden_text": ["КОНТРОЛЬНЫЙ ОТЧЁТ", "СПИСОК ИСПОЛНИТЕЛЕЙ"],
"snapshot_pages": []
},
{
"id": "report-service-pages-title-only",
"entry": ".template/development/tests/fixtures/report-service-pages/main.typ",
"inputs": {"title": "true", "executors": "false", "outline": "false"},
"expected_pages": 2,
"expected_page_size": "A4",
"required_text": ["КОНТРОЛЬНЫЙ ОТЧЁТ", "ОСНОВНОЙ ТЕКСТ"],
"forbidden_text": ["СПИСОК ИСПОЛНИТЕЛЕЙ", "СОДЕРЖАНИЕ"],
"snapshot_pages": []
},
{
"id": "report-service-pages-executors-only",
"entry": ".template/development/tests/fixtures/report-service-pages/main.typ",
"inputs": {"title": "false", "executors": "true", "outline": "false"},
"expected_pages": 2,
"expected_page_size": "A4",
"required_text": ["СПИСОК ИСПОЛНИТЕЛЕЙ", "ОСНОВНОЙ ТЕКСТ"],
"forbidden_text": ["КОНТРОЛЬНЫЙ ОТЧЁТ", "СОДЕРЖАНИЕ"],
"snapshot_pages": []
},
{
"id": "report-service-pages-body-only",
"entry": ".template/development/tests/fixtures/report-service-pages/main.typ",
"inputs": {"title": "false", "executors": "false", "outline": "false"},
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": ["ОСНОВНОЙ ТЕКСТ", "Основной текст контрольного отчёта"],
"forbidden_text": ["КОНТРОЛЬНЫЙ ОТЧЁТ", "СПИСОК ИСПОЛНИТЕЛЕЙ", "СОДЕРЖАНИЕ"],
"snapshot_pages": []
},
{
"id": "presentation-smoke",
"entry": ".template/development/tests/fixtures/presentation-smoke/main.typ",
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": [
"ПРОВЕРКА КОМПОНЕНТОВ",
"Общая presentation-основа",
"Media slot"
],
"snapshot_pages": [1]
},
{
"id": "report-new",
"entry": ".template/development/tests/fixtures/report-new/main.typ",
"expected_pages": 7,
"expected_page_size": "A4",
"required_text": [
"Проверка DDD-профиля",
"НОРМАТИВНЫЕ ИСТОЧНИКИ",
"НАУЧНЫЕ ИСТОЧНИКИ",
"Приложение А"
],
"snapshot_pages": [1, 2, 5, 6]
},
{
"id": "report-stress",
"entry": ".template/development/tests/fixtures/report-stress/main.typ",
"expected_pages": 12,
"expected_page_size": "A4",
"required_text": [
"Таблицы, списки, формулы и грамматические ссылки",
"1. Основной пункт",
"а) Второй уровень",
"1) Третий уровень",
"на Рисунках 2.1 и 2.2",
"контрольная спецификация",
"Продолжение таблицы 3.1",
"Ссылка на Таблицу 3.1",
"Продолжение высокой таблицы 5.1",
"Строка высокой ячейки 65."
],
"required_text_counts": {
"Контрольные данные": 6,
"Продолжение таблицы 3.1": 5,
"Высокая ячейка": 3,
"Продолжение высокой таблицы 5.1": 2
},
"minimum_external_links": 2,
"minimum_internal_links": 8,
"snapshot_pages": [3, 4, 5, 9, 10, 11, 12]
},
{
"id": "reference-cases",
"entry": ".template/development/tests/fixtures/reference-cases/main.typ",
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": [
"Предложный по умолчанию: на Рисунке",
"Именительный коротко: Рисунок",
"Родительный коротко: без Рисунка",
"Дательный коротко: к Рисунку",
"Винительный коротко: вижу Рисунок",
"Творительный коротко: перед Рисунком",
"Предложный коротко: на Рисунке",
"Явная строчная форма: на рисунке",
"Полное название: на Таблице",
"Формула: согласно Формуле",
"Раздел: в Разделе",
"Группа по умолчанию: на Рисунках"
],
"snapshot_pages": []
},
{
"id": "report-appendices",
"entry": ".template/development/tests/fixtures/report-appendices/main.typ",
"expected_pages": 4,
"expected_page_size": "A4",
"required_text": [
"Приложение А. Исходные данные",
"Приложение Б. Проверочные расчёты",
"Стандартная ссылка: Приложение А",
"Именительный: Приложение А",
"Родительный: без Приложения А",
"Дательный: к Приложению А",
"Винительный: вижу Приложение А",
"Творительный: перед Приложением А",
"Предложный по умолчанию: в Приложении А",
"Группа приложений: в Приложениях А и Б",
"Рисунок А.1",
"Таблица Б.1"
],
"minimum_internal_links": 8,
"snapshot_pages": []
},
{
"id": "letter",
"entry": ".template/development/tests/fixtures/letter/main.typ",
"expected_pages": 2,
"expected_page_size": "A4",
"required_text": [
"О НАПРАВЛЕНИИ МАТЕРИАЛОВ",
"Приложение 1: Краткая спецификация",
"КРАТКАЯ СПЕЦИФИКАЦИЯ"
],
"snapshot_pages": [1, 2]
},
{
"id": "commercial-offer",
"entry": ".template/development/tests/fixtures/commercial-offer/main.typ",
"expected_pages": 2,
"expected_page_size": "A4",
"required_text": [
"ТЕХНИКО-КОММЕРЧЕСКОЕ ПРЕДЛОЖЕНИЕ",
"1 250 000 руб.",
"ТЕХНИЧЕСКОЕ ЗАДАНИЕ"
],
"snapshot_pages": [1, 2]
},
{
"id": "contract",
"entry": ".template/development/tests/fixtures/contract/main.typ",
"expected_pages": 3,
"expected_page_size": "A4",
"required_text": [
"ДОГОВОР ОКАЗАНИЯ УСЛУГ",
"РЕКВИЗИТЫ И ПОДПИСИ СТОРОН",
"СПЕЦИФИКАЦИЯ"
],
"snapshot_pages": [1, 2, 3]
},
{
"id": "root-report",
"entry": "main.typ",
"expected_pages": 0,
"expected_page_size": "A4",
"required_text": [
"ГЕОМЕХАНИЧЕСКОЕ ОБОСНОВАНИЕ УСТОЙЧИВОСТИ БОРТОВ",
"СПИСОК ИСПОЛНИТЕЛЕЙ",
"Результаты проверочных расчётов",
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
"Приложение А. Исходные данные",
"Приложение Б. Дополнительные расчёты",
"Рисунок Б.1"
],
"snapshot_pages": []
},
{
"id": "public-report",
"entry": "docs/examples/documents/report/main.typ",
"expected_pages": 0,
"expected_page_size": "A4",
"required_text": [
"Результаты проверочных расчётов",
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
"Приложение А. Исходные данные",
"Приложение Б. Дополнительные расчёты",
"Рисунок Б.1"
],
"snapshot_pages": []
},
{
"id": "public-letter",
"entry": "docs/examples/documents/letter/main.typ",
"expected_pages": 0,
"expected_page_size": "A4",
"required_text": ["О НАПРАВЛЕНИИ МАТЕРИАЛОВ ЭТАПА 1", "Уважаемый Иван Иванович"],
"snapshot_pages": []
},
{
"id": "public-commercial-offer",
"entry": "docs/examples/documents/commercial-offer/main.typ",
"expected_pages": 0,
"expected_page_size": "A4",
"required_text": ["Геомеханическое сопровождение горных работ", "Состав и результат работ", "ТЕХНИЧЕСКОЕ ЗАДАНИЕ"],
"snapshot_pages": []
},
{
"id": "public-contract",
"entry": "docs/examples/documents/contract/main.typ",
"expected_pages": 0,
"expected_page_size": "A4",
"required_text": ["ДОГОВОР ОКАЗАНИЯ УСЛУГ", "ОТВЕТСТВЕННОСТЬ СТОРОН", "ТЕХНИЧЕСКОЕ ЗАДАНИЕ"],
"snapshot_pages": []
},
{
"id": "report-draft",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"inputs": {"profile": "report", "mode": "draft"},
"expected_pages": 3,
"expected_page_size": "A4",
"required_text": ["Документ собран в режиме draft"],
"snapshot_pages": []
},
{
"id": "report-clean-copy",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"inputs": {"profile": "report", "mode": "clean-copy"},
"expected_pages": 3,
"expected_page_size": "A4",
"required_text": ["Документ собран в режиме clean-copy"],
"snapshot_pages": []
},
{
"id": "letter-draft",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"inputs": {"profile": "letter", "mode": "draft"},
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": ["Документ собран в режиме draft"],
"snapshot_pages": []
},
{
"id": "letter-clean-copy",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"inputs": {"profile": "letter", "mode": "clean-copy"},
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": ["Документ собран в режиме clean-copy"],
"snapshot_pages": []
},
{
"id": "commercial-offer-draft",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"inputs": {"profile": "commercial-offer", "mode": "draft"},
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": ["Документ собран в режиме draft"],
"snapshot_pages": []
},
{
"id": "commercial-offer-clean-copy",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"inputs": {"profile": "commercial-offer", "mode": "clean-copy"},
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": ["Документ собран в режиме clean-copy"],
"snapshot_pages": []
},
{
"id": "contract-draft",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"inputs": {"profile": "contract", "mode": "draft"},
"expected_pages": 2,
"expected_page_size": "A4",
"required_text": ["Документ собран в режиме draft"],
"snapshot_pages": []
},
{
"id": "contract-clean-copy",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"inputs": {"profile": "contract", "mode": "clean-copy"},
"expected_pages": 2,
"expected_page_size": "A4",
"required_text": ["Документ собран в режиме clean-copy"],
"snapshot_pages": []
},
{
"id": "no-media-placeholder",
"entry": ".template/development/tests/fixtures/no-media/main.typ",
"inputs": {"policy": "placeholder"},
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": ["политикой placeholder"],
"snapshot_pages": [1]
},
{
"id": "no-media-hide",
"entry": ".template/development/tests/fixtures/no-media/main.typ",
"inputs": {"policy": "hide"},
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": ["политикой hide"],
"snapshot_pages": []
},
{
"id": "no-media-reserve-space",
"entry": ".template/development/tests/fixtures/no-media/main.typ",
"inputs": {"policy": "reserve-space"},
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": ["политикой reserve-space"],
"snapshot_pages": []
},
{
"id": "report-baseline",
"entry": ".template/development/tests/fixtures/report-baseline/main.typ",
"expected_pages": 7,
"expected_page_size": "A4",
"required_text": [
"ТЕСТОВЫЙ ОТЧЁТ",
"СПИСОК ИСПОЛНИТЕЛЕЙ",
"СОДЕРЖАНИЕ",
"Сложные элементы должны сохранять",
"ТЕСТОВОЕ ПРИЛОЖЕНИЕ"
],
"snapshot_pages": [1, 2, 3, 4]
},
{
"id": "list-counters",
"entry": ".template/development/tests/fixtures/list-counters/main.typ",
"expected_pages": 1,
"expected_page_size": "A4",
"required_text": [
"1. Арабские: первый",
"2. Арабские: второй",
"3. Арабские: третий",
"А. Кириллица верхняя: первый",
"Б. Кириллица верхняя: второй",
"В. Кириллица верхняя: третий",
"а. Кириллица нижняя: первый",
"б. Кириллица нижняя: второй",
"в. Кириллица нижняя: третий",
"A. Латиница верхняя: первый",
"B. Латиница верхняя: второй",
"C. Латиница верхняя: третий",
"a. Латиница нижняя: первый",
"b. Латиница нижняя: второй",
"c. Латиница нижняя: третий",
"01. Ведущий ноль: первый",
"02. Ведущий ноль: второй",
"03. Ведущий ноль: третий",
"1.а. Второй уровень: первый",
"1.б. Второй уровень: второй",
"1.б.A. Третий уровень: первый",
"1.б.B. Третий уровень: второй"
],
"snapshot_pages": []
},
{
"id": "public-formatting-guide",
"entry": "docs/examples/formatting/main.typ",
"expected_pages": 0,
"expected_page_size": "A4",
"required_text": [
"РИСУНКИ",
"ТАБЛИЦЫ",
"Обычная встроенная таблица",
"Многострочная шапка и объединение ячеек",
"ФОРМУЛЫ",
"СПИСКИ И ТЕКСТ",
"Быстрая многоуровневая схема ГОСТ",
"Полная десятичная нумерация",
"Доступные типы счётчиков",
"Произвольные разделители",
"Маркеры вместо чисел",
"Геометрия и интервалы",
"Продолжение с нужного номера",
"1. Первый уровень.",
"а) Второй уровень.",
"1.1.1. Вложенный пункт.",
"II. Второй.",
"III. Третий.",
"ii. Второй.",
"iii. Третий.",
"B. Второй.",
"C. Третий.",
"b. Второй.",
"c. Третий.",
"02. Второй.",
"03. Третий.",
"Б. Второй.",
"В. Третий.",
"б. Второй.",
"в. Третий.",
"1.а)A. Третий уровень",
"A)1. Цифровой уровень.",
"§ 001: Пользовательский уровень",
"• Маркер •.",
"∙ Маркер ∙.",
"‣ Маркер ‣.",
"⁃ Маркер ⁃.",
"◦ Маркер ◦.",
"08. Восьмой пункт",
"09. Следующий пункт"
],
"snapshot_pages": []
}
],
"negative_fixtures": [
{
"id": "negative-letter",
"entry": ".template/development/tests/fixtures/negative-letter/main.typ",
"required_errors": ["Scientia / letter.recipient"]
},
{
"id": "negative-offer",
"entry": ".template/development/tests/fixtures/negative-offer/main.typ",
"required_errors": ["Scientia / commercial-offer.terms.amount"]
},
{
"id": "negative-contract",
"entry": ".template/development/tests/fixtures/negative-contract/main.typ",
"required_errors": ["Scientia / parties", "повторяющийся id"]
},
{
"id": "negative-company",
"entry": ".template/development/tests/fixtures/negative-company/main.typ",
"required_errors": ["Scientia / load-company.id", "неизвестная компания"]
},
{
"id": "negative-references",
"entry": ".template/development/tests/fixtures/negative-references/main.typ",
"required_errors": ["Scientia / vrefs", "ожидался label"]
},
{
"id": "negative-reference-case",
"entry": ".template/development/tests/fixtures/negative-reference-case/main.typ",
"required_errors": ["Scientia / reference", "неизвестный падеж"]
},
{
"id": "negative-list-scheme",
"entry": ".template/development/tests/fixtures/negative-list-scheme/main.typ",
"required_errors": ["Scientia / numbered-list.scheme", "неизвестная схема"]
},
{
"id": "negative-report-appendices",
"entry": ".template/development/tests/fixtures/negative-report-appendices/main.typ",
"required_errors": ["Scientia / report.appendices", "каждый элемент должен быть path"]
}
],
"compile_matrices": [
{
"id": "company-profile-matrix",
"entry": ".template/development/tests/fixtures/modes/main.typ",
"parameters": {
"company": ["scientia", "technology", "too", "test-company"],
"profile": ["report", "letter", "commercial-offer", "contract"],
"mode": ["final"]
}
}
]
}
+645
View File
@@ -0,0 +1,645 @@
from __future__ import annotations
import argparse
import itertools
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
TEST_ROOT = ROOT / ".template" / "development" / "tests"
MANIFEST_PATH = TEST_ROOT / "manifest.json"
SNAPSHOT_ROOT = TEST_ROOT / "snapshots"
class TestFailure(RuntimeError):
pass
def check_architecture() -> None:
"""Проверяет направление зависимостей без запуска Typst renderer."""
template_root = ROOT / ".template" / "lib"
import_pattern = re.compile(r'#import\s+"([^"]+)"')
graph: dict[Path, set[Path]] = {}
for source in template_root.rglob("*.typ"):
graph[source.resolve()] = set()
text = source.read_text(encoding="utf-8")
source_area = source.relative_to(template_root).parts[0]
code_without_line_comments = re.sub(r"//.*", "", text)
if source_area == "domain":
forbidden_runtime = re.search(
r"(?:#(?:set|show)\b|\b(?:page|place|image|query|state)\s*\()",
code_without_line_comments,
)
if forbidden_runtime:
raise TestFailure(
f"Domain-модуль {source.relative_to(ROOT)} содержит presentation/runtime вызов "
f"{forbidden_runtime.group(0)!r}."
)
for raw_target in import_pattern.findall(text):
if raw_target.startswith("@"):
continue
target = (source.parent / raw_target).resolve()
try:
relative = target.relative_to(template_root.resolve())
except ValueError:
continue
if target.suffix == ".typ":
graph[source.resolve()].add(target)
target_area = relative.parts[0]
forbidden = {
"domain": {"application", "infrastructure", "presentation"},
"infrastructure": {"application", "presentation"},
"application": {"presentation"},
}.get(source_area, set())
if target_area in forbidden:
raise TestFailure(
"Нарушена DDD-граница: "
f"{source.relative_to(ROOT)} -> {target.relative_to(ROOT)}"
)
if re.search(r'#import\s+"[^\"]*(?:main\.typ|chapters/|assets/|\.private/)', text):
raise TestFailure(
f"Library-модуль {source.relative_to(ROOT)} импортирует пользовательский слой."
)
visiting: set[Path] = set()
visited: set[Path] = set()
def visit(node: Path, stack: list[Path]) -> None:
if node in visiting:
cycle = stack[stack.index(node):] + [node]
rendered = " -> ".join(str(item.relative_to(ROOT)) for item in cycle)
raise TestFailure(f"Циклические Typst-импорты: {rendered}")
if node in visited:
return
visiting.add(node)
stack.append(node)
for target in graph.get(node, set()):
if target in graph:
visit(target, stack)
stack.pop()
visiting.remove(node)
visited.add(node)
for node in graph:
visit(node, [])
def check_workspace() -> None:
"""Проверяет минимальный корень и синхронизируемую конфигурацию VS Code."""
allowed_directories = {
".git",
".private",
".template",
".vscode",
"assets",
"chapters",
"docs",
}
actual_directories = {item.name for item in ROOT.iterdir() if item.is_dir()}
unexpected = actual_directories - allowed_directories
if unexpected:
raise TestFailure(
"В корне обнаружены лишние каталоги: " + ", ".join(sorted(unexpected))
)
required_files = {
".gitignore",
"README.md",
"main.typ",
}
missing_files = {name for name in required_files if not (ROOT / name).is_file()}
if missing_files:
raise TestFailure(
"В корне отсутствуют обязательные файлы: " + ", ".join(sorted(missing_files))
)
extensions_path = ROOT / ".vscode" / "extensions.json"
settings_path = ROOT / ".vscode" / "settings.json"
extensions = json.loads(extensions_path.read_text(encoding="utf-8"))
recommendations = set(extensions.get("recommendations", []))
required_extensions = {
"alefragnani.bookmarks",
"streetsidesoftware.code-spell-checker",
"streetsidesoftware.code-spell-checker-russian",
"ms-ceintl.vscode-language-pack-ru",
"myriad-dreamin.tinymist",
"wayou.vscode-todo-highlight",
"gruntfuggly.todo-tree",
"mhutchie.git-graph",
"local.typst-typewriter",
"zotst.zotst",
}
missing_extensions = required_extensions - recommendations
if missing_extensions:
raise TestFailure(
"В extensions.json отсутствуют рекомендации: "
+ ", ".join(sorted(missing_extensions))
)
settings = json.loads(settings_path.read_text(encoding="utf-8"))
if settings.get("files.autoSave") != "afterDelay":
raise TestFailure("Workspace должен включать files.autoSave=afterDelay.")
if settings.get("files.exclude", {}).get(".template") is not True:
raise TestFailure(".template должен быть скрыт в пользовательском Explorer VS Code.")
if settings.get("files.exclude", {}).get(".vscode") is not True:
raise TestFailure(".vscode должен быть скрыт в пользовательском Explorer VS Code.")
if settings.get("files.exclude", {}).get("docs") is True:
raise TestFailure("Публичная документация docs/ не должна быть скрыта.")
if settings.get("typstTypewriter.navigator.mainFiles") != ["main.typ"]:
raise TestFailure("Typewriter должен показывать единственный main.typ.")
gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8")
if ".private/" not in gitignore:
raise TestFailure("В .gitignore отсутствует защита каталога .private/.")
if ".vscode/" in gitignore:
raise TestFailure(".vscode должен синхронизироваться через Git.")
private_example = ROOT / "docs" / "examples" / "private" / "settings.typ"
if not private_example.is_file():
raise TestFailure("Отсутствует безопасный пример .private/settings.typ.")
private_example_text = private_example.read_text(encoding="utf-8")
for employee_id in (
"musikhin", "guzeev", "fedorov", "ilyasov", "khimichev",
"brusnicin", "ozornin", "buhartdinov", "tkachenko",
):
if f"{employee_id}:" not in private_example_text:
raise TestFailure(
f"В примере private settings отсутствует сотрудник {employee_id}."
)
tasks = json.loads((ROOT / ".vscode" / "tasks.json").read_text(encoding="utf-8"))
task_labels = {task.get("label") for task in tasks.get("tasks", [])}
if "Scientia: собрать PDF с приватными данными" in task_labels:
raise TestFailure("Отдельная private build task больше не должна использоваться.")
if (ROOT / ".template" / "development" / "tools" / "build-with-private-assets.ps1").exists():
raise TestFailure("Устаревший ZIP installer должен быть удалён.")
for company_id in ("scientia", "technology", "too"):
company_path = ROOT / ".template" / "companies" / company_id / "data.json"
company = json.loads(company_path.read_text(encoding="utf-8"))
if company.get("sign_image") is not None or company.get("stamp_image") is not None:
raise TestFailure(
f"Публичный профиль {company_id} не должен содержать подпись или печать."
)
public_docs = (
"README.md",
"documents.md",
"formatting.md",
"vscode.md",
"git.md",
"private-assets.md",
"writing-style.md",
"troubleshooting.md",
)
for name in public_docs:
if not (ROOT / "docs" / name).is_file():
raise TestFailure(f"Публичная документация неполна: docs/{name}.")
for starter in ("report", "letter", "commercial-offer", "contract"):
starter_root = ROOT / "docs" / "examples" / "documents" / starter
if not (starter_root / "main.typ").is_file() or not (starter_root / "chapters").is_dir():
raise TestFailure(f"Публичный пример {starter} неполон.")
for obsolete in ("document.typ", "draft.typ", "clean-copy.typ"):
if (ROOT / obsolete).exists():
raise TestFailure(f"Лишняя точка входа в корне: {obsolete}.")
def run(command: list[str], *, cwd: Path = ROOT) -> str:
result = subprocess.run(
command,
cwd=cwd,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if result.returncode != 0:
rendered = subprocess.list2cmdline(command)
raise TestFailure(f"Команда завершилась с кодом {result.returncode}:\n{rendered}\n{result.stdout}")
return result.stdout
def find_executable(
name: str,
*,
env_var: str | None = None,
required: bool = True,
) -> Path | None:
if env_var and os.environ.get(env_var):
candidate = Path(os.environ[env_var])
if candidate.is_file():
return candidate
located = shutil.which(name)
if located:
return Path(located)
executable = name + (".exe" if os.name == "nt" else "")
candidates: list[Path] = []
poppler_bin = os.environ.get("POPPLER_BIN")
if poppler_bin:
candidates.append(Path(poppler_bin) / executable)
if os.name == "nt":
candidates.append(
Path.home()
/ ".cache"
/ "codex-runtimes"
/ "codex-primary-runtime"
/ "dependencies"
/ "native"
/ "poppler"
/ "Library"
/ "bin"
/ executable
)
for candidate in candidates:
if candidate.is_file():
return candidate
if required:
raise TestFailure(
f"Не найден исполняемый файл {name}. Добавьте его в PATH"
+ (f" или задайте {env_var}" if env_var else "")
+ ("/POPPLER_BIN." if name.startswith("pdf") else ".")
)
return None
def parse_version(value: str) -> tuple[int, ...]:
match = re.search(r"(\d+)\.(\d+)\.(\d+)", value)
if not match:
raise TestFailure(f"Не удалось определить версию из строки: {value!r}")
return tuple(int(part) for part in match.groups())
def load_manifest() -> dict:
return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
def compile_fixture(
typst: Path,
entry: Path,
output: Path,
creation_timestamp: int,
inputs: dict[str, str] | None = None,
) -> str:
command = [
str(typst),
"compile",
"--root",
str(ROOT),
"--creation-timestamp",
str(creation_timestamp),
]
for key, value in sorted((inputs or {}).items()):
command.extend(("--input", f"{key}={value}"))
command.extend((str(entry), str(output)))
return run(command)
def compile_expected_failure(
typst: Path,
entry: Path,
output: Path,
creation_timestamp: int,
required_errors: list[str],
inputs: dict[str, str] | None = None,
) -> None:
command = [
str(typst),
"compile",
"--root",
str(ROOT),
"--creation-timestamp",
str(creation_timestamp),
]
for key, value in sorted((inputs or {}).items()):
command.extend(("--input", f"{key}={value}"))
command.extend((str(entry), str(output)))
result = subprocess.run(
command,
cwd=ROOT,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if result.returncode == 0:
raise TestFailure(f"{entry.relative_to(ROOT)}: ожидалась ошибка компиляции.")
for marker in required_errors:
if marker not in result.stdout:
raise TestFailure(
f"{entry.relative_to(ROOT)}: в diagnostic не найден маркер {marker!r}.\n"
f"Фактический вывод:\n{result.stdout}"
)
def run_unit_test(typst: Path, module: str, creation_timestamp: int) -> None:
expression = f'import "/{module.replace(os.sep, "/")}": run; run()'
output = run(
[
str(typst),
"eval",
"--root",
str(ROOT),
"--creation-timestamp",
str(creation_timestamp),
expression,
]
).strip()
if json.loads(output) != "ok":
raise TestFailure(f"Unit-модуль {module} вернул неожиданное значение: {output}")
def inspect_pdf(pdfinfo: Path, pdf: Path) -> tuple[int, str]:
info = run([str(pdfinfo), str(pdf)])
pages_match = re.search(r"^Pages:\s+(\d+)$", info, flags=re.MULTILINE)
size_match = re.search(r"^Page size:.*\(([^)]+)\)$", info, flags=re.MULTILINE)
if not pages_match or not size_match:
raise TestFailure(f"Не удалось разобрать pdfinfo для {pdf}:\n{info}")
return int(pages_match.group(1)), size_match.group(1).strip()
def extract_text(pdftotext: Path | None, pdf: Path, target: Path) -> str:
if pdftotext:
run([str(pdftotext), "-layout", str(pdf), str(target)])
return target.read_text(encoding="utf-8", errors="replace")
try:
from pypdf import PdfReader
except ImportError as exc:
raise TestFailure(
"Для проверки текста требуется pdftotext или Python-пакет pypdf."
) from exc
reader = PdfReader(str(pdf))
return "\n".join((page.extract_text() or "") for page in reader.pages)
def count_pdf_links(pdf: Path) -> tuple[int, int]:
try:
from pypdf import PdfReader
except ImportError as exc:
raise TestFailure("Для проверки PDF-ссылок требуется Python-пакет pypdf.") from exc
external = internal = 0
for page in PdfReader(str(pdf)).pages:
for raw_annotation in page.get("/Annots", ()):
annotation = raw_annotation.get_object()
if annotation.get("/Subtype") != "/Link":
continue
action = annotation.get("/A")
if action is not None and action.get_object().get("/URI") is not None:
external += 1
else:
internal += 1
return external, internal
def render_page(pdftoppm: Path, pdf: Path, page: int, dpi: int, target: Path) -> Path:
target.parent.mkdir(parents=True, exist_ok=True)
run(
[
str(pdftoppm),
"-png",
"-r",
str(dpi),
"-f",
str(page),
"-l",
str(page),
"-singlefile",
str(pdf),
str(target.with_suffix("")),
]
)
return target
def image_difference(expected: Path, actual: Path) -> float:
try:
from PIL import Image, ImageChops
except ImportError as exc:
raise TestFailure("Для visual regression требуется пакет Pillow.") from exc
with Image.open(expected).convert("RGB") as left, Image.open(actual).convert("RGB") as right:
if left.size != right.size:
return 1.0
diff = ImageChops.difference(left, right)
histogram = diff.histogram()
total = sum(value * (index % 256) for index, value in enumerate(histogram))
maximum = left.width * left.height * 3 * 255
return total / maximum
def main() -> int:
parser = argparse.ArgumentParser(description="Регрессионные тесты шаблона документов Scientia")
parser.add_argument("--fixture", action="append", help="Запустить только fixture с указанным id")
parser.add_argument("--compile-only", action="store_true", help="Не запускать PDF и visual проверки")
parser.add_argument(
"--update-snapshots",
action="store_true",
help="Явно обновить visual snapshots после ручного подтверждения",
)
args = parser.parse_args()
manifest = load_manifest()
typst = find_executable("typst", env_var="TYPST_BIN")
version_output = run([str(typst), "--version"]).strip()
if parse_version(version_output) < parse_version(manifest["typst_min"]):
raise TestFailure(
f"Требуется Typst {manifest['typst_min']}+, найден {version_output}."
)
pdfinfo = pdftotext = pdftoppm = None
if not args.compile_only:
pdfinfo = find_executable("pdfinfo")
pdftotext = find_executable("pdftotext", required=False)
pdftoppm = find_executable("pdftoppm")
creation_timestamp = int(manifest["creation_timestamp"])
selected = set(args.fixture or [])
known = {
item["id"]
for item in (
*manifest["fixtures"],
*manifest.get("negative_fixtures", []),
*manifest.get("compile_matrices", []),
)
}
unknown = selected - known
if unknown:
raise TestFailure(f"Неизвестные fixtures: {', '.join(sorted(unknown))}")
if not selected:
print("[static] architecture")
check_architecture()
print("[static] workspace")
check_workspace()
for unit in manifest.get("unit_tests", []):
if selected:
continue
print(f"[unit] {unit['id']}")
run_unit_test(typst, unit["module"], creation_timestamp)
temp_dir = Path(tempfile.mkdtemp(prefix="scientia-tests-"))
failed = False
try:
for matrix in manifest.get("compile_matrices", []):
if selected and matrix["id"] not in selected:
continue
keys = tuple(matrix["parameters"])
values = tuple(matrix["parameters"][key] for key in keys)
for combination in itertools.product(*values):
inputs = dict(zip(keys, combination))
suffix = ",".join(f"{key}={value}" for key, value in inputs.items())
print(f"[matrix] {matrix['id']}[{suffix}]")
safe_suffix = re.sub(r"[^A-Za-z0-9_.-]+", "-", suffix)
compile_fixture(
typst,
ROOT / matrix["entry"],
temp_dir / f"{matrix['id']}-{safe_suffix}.pdf",
creation_timestamp,
inputs=inputs,
)
for fixture in manifest.get("negative_fixtures", []):
if selected and fixture["id"] not in selected:
continue
fixture_id = fixture["id"]
print(f"[negative] {fixture_id}")
compile_expected_failure(
typst,
ROOT / fixture["entry"],
temp_dir / f"{fixture_id}.pdf",
creation_timestamp,
fixture.get("required_errors", []),
inputs=fixture.get("inputs"),
)
for fixture in manifest["fixtures"]:
if selected and fixture["id"] not in selected:
continue
fixture_id = fixture["id"]
print(f"[compile] {fixture_id}")
entry = ROOT / fixture["entry"]
output = temp_dir / f"{fixture_id}.pdf"
compile_fixture(
typst,
entry,
output,
creation_timestamp,
inputs=fixture.get("inputs"),
)
if args.compile_only:
continue
assert pdfinfo and pdftoppm
pages, page_size = inspect_pdf(pdfinfo, output)
expected_pages = int(fixture["expected_pages"])
if expected_pages and pages != expected_pages:
raise TestFailure(
f"{fixture_id}: ожидалось {expected_pages} страниц, получено {pages}."
)
if page_size != fixture["expected_page_size"]:
raise TestFailure(
f"{fixture_id}: ожидался формат {fixture['expected_page_size']}, получен {page_size}."
)
text = extract_text(pdftotext, output, temp_dir / f"{fixture_id}.txt")
for marker in fixture.get("required_text", []):
if marker not in text:
raise TestFailure(f"{fixture_id}: в PDF не найден текстовый маркер {marker!r}.")
for marker in fixture.get("forbidden_text", []):
if marker in text:
raise TestFailure(f"{fixture_id}: в PDF найден запрещённый текстовый маркер {marker!r}.")
for marker, minimum in fixture.get("required_text_counts", {}).items():
actual = text.count(marker)
if actual < int(minimum):
raise TestFailure(
f"{fixture_id}: маркер {marker!r} найден {actual} раз; "
f"требуется не менее {minimum}."
)
if "minimum_external_links" in fixture or "minimum_internal_links" in fixture:
external_links, internal_links = count_pdf_links(output)
minimum_external = int(fixture.get("minimum_external_links", 0))
minimum_internal = int(fixture.get("minimum_internal_links", 0))
if external_links < minimum_external:
raise TestFailure(
f"{fixture_id}: внешних PDF-ссылок {external_links}; "
f"требуется не менее {minimum_external}."
)
if internal_links < minimum_internal:
raise TestFailure(
f"{fixture_id}: внутренних PDF-ссылок {internal_links}; "
f"требуется не менее {minimum_internal}."
)
for page in fixture.get("snapshot_pages", []):
if page > pages:
raise TestFailure(f"{fixture_id}: snapshot page {page} больше количества страниц {pages}.")
actual = render_page(
pdftoppm,
output,
page,
int(manifest["dpi"]),
temp_dir / fixture_id / f"page-{page}.png",
)
expected = SNAPSHOT_ROOT / fixture_id / f"page-{page}.png"
if args.update_snapshots:
expected.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(actual, expected)
print(f"[snapshot:update] {expected.relative_to(ROOT)}")
continue
if not expected.is_file():
raise TestFailure(
f"Нет snapshot {expected.relative_to(ROOT)}. "
"После ручной проверки запустите --update-snapshots."
)
difference = image_difference(expected, actual)
threshold = float(fixture.get("visual_threshold", manifest["visual_threshold"]))
if difference > threshold:
raise TestFailure(
f"{fixture_id}, страница {page}: visual diff {difference:.6f} "
f"превышает порог {threshold:.6f}. Фактический файл: {actual}"
)
print(f"[snapshot:ok] {fixture_id}/page-{page} diff={difference:.6f}")
print(f"OK: {version_output}")
return 0
except Exception:
failed = True
raise
finally:
if failed:
print(f"Временные результаты сохранены: {temp_dir}", file=sys.stderr)
else:
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
try:
raise SystemExit(main())
except TestFailure as exc:
print(f"FAIL: {exc}", file=sys.stderr)
raise SystemExit(1)