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

+251
View File
@@ -0,0 +1,251 @@
# Архитектура шаблона документов Scientia
## Системный контекст
```text
┌────────────────────┐ редактирует ┌──────────────────────────────────┐
│ Автор документа │ ────────────▶ │ main.typ + chapters/ + assets/ │
└─────────┬──────────┘ └────────────────┬─────────────────┘
│ читает │ один import фасада
▼ ▼
┌────────────────────┐ ┌──────────────────────────────────┐
│ docs/ │ │ .template/lib/ │
│ инструкции и │ │ domain → application → │
│ примеры │ │ infrastructure → presentation │
└────────────────────┘ └────────────────┬─────────────────┘
│
┌────────────────────┐ условный import │ PDF
│ .private/ │ ────────────▶ main.typ ─────────┤
│ PNG + settings.typ │ один boolean ▼
┌────────────────────┐
│ document.pdf │
└────────────────────┘
┌────────────────────┐ сопровождает ┌─────────────────────────────────┐
│ Разработчик │ ─────────────▶ │ .template/development/ │
│ шаблона │ │ docs + modules + tests + tools │
└────────────────────┘ └─────────────────────────────────┘
```
Публичная и developer-документация физически разделены. Автору не требуется открывать `.template/`, а разработчик не использует `docs/` как описание внутренних контрактов.
## Архитектурные принципы
1. **Один файл ежедневной настройки**. Компания, режим, metadata и `#include` находятся в `main.typ`.
2. **Режим — значение, а не entrypoint**. `final`, `draft` и `clean-copy` являются вариантами одной переменной.
3. **Публичное видно**. Инструкции и примеры находятся в `docs/`, который VS Code не скрывает.
4. **Разработка скрыта**. Библиотека, tests, ADR и tools находятся в `.template/`.
5. **Пример является исполняемой документацией**. Один исходник одновременно обучает, компилируется в CI и устанавливается как тип документа.
6. **Важные параметры явные**. Даже отключённые значения показаны как `none`, `false` или `()`.
7. **Приватное не отслеживается**. Вся папка `.private/`, включая настройки offsets, исключена из Git; tracked placeholders никогда не заменяются реальными файлами.
8. **Один переключатель**. При `false` условный import не читает `.private`; при `true` обычный preview и обычная build task используют её настройки.
9. **Фасад скрывает реализацию**. Пользователь импортирует только `/.template/lib/index.typ`.
10. **Domain не зависит от layout**. Presentation использует domain contracts, но обратной зависимости нет.
## Целевая структура
```text
README.md # короткий маршрут автора
main.typ # единственная точка входа и настройки
chapters/ # пользовательский текст
assets/ # изображения, данные и bibliography
docs/ # публичная документация
├── README.md
├── documents.md
├── formatting.md
├── vscode.md
├── git.md
├── private-assets.md
├── writing-style.md
├── troubleshooting.md
└── examples/
├── README.md
├── private/
│ └── settings.typ
├── documents/
│ ├── report/
│ ├── letter/
│ ├── commercial-offer/
│ └── contract/
└── formatting/
.vscode/ # tracked workspace configuration
├── extensions.json
├── settings.json
└── tasks.json
.private/ # ignored settings, private media и backups
├── settings.typ
├── executors/
├── scientia/
├── technology/
└── too/
.template/ # скрытая реализация
├── lib/
├── companies/
└── development/
├── docs/
├── modules/
├── tests/
├── tools/
└── vscode/
```
В чистом fork каталог `.private/` не обязателен. Обычная компиляция использует placeholders из `.template/lib/assets/placeholders/`.
## Компоненты
| Компонент | Ответственность | Публичный интерфейс |
|-----------|-----------------|---------------------|
| Рабочее пространство автора | Один входной файл, главы и ресурсы | `main.typ`, `chapters/`, `assets/` |
| Публичная документация | Обучение без знания реализации | `README.md`, `docs/*.md` |
| Публичные примеры | Исполняемые примеры и источники выбора типа | `docs/examples/**/main.typ` |
| VS Code workspace | Рекомендации, автосохранение и задачи | `.vscode/*.json` |
| Публичный фасад | Единственный пользовательский Typst import | `.template/lib/index.typ` |
| Document Domain | Профиль, context и render options | `document-profile()`, `render-options()` |
| Company Domain | Реквизиты и firm resources | `company-profile()` |
| Parties Domain | Адресаты, подписанты и стороны | `recipient()`, `signer()`, `party()` |
| Attachments Domain | Порядок и идентичность приложений | `attachment()`, `attachment-set()` |
| Render Application | Resolve, normalize, validate, render | `render-document()` |
| Company Adapter | JSON profiles и resource overrides | `load-company()` |
| Presentation | Foundation, components и четыре renderer | `profiles.report/letter/commercial_offer/contract` |
| Employee/Private Adapter | Справочник сотрудников, private media и offsets | `report-executor()`, `private-company-media()` |
| Test Harness | Static, compile, semantic и visual gates | `run-tests.py` |
## Data flow
### Обычная сборка
1. Автор выбирает `company-id` и `document-mode` в `main.typ`.
2. `main.typ` создаёт profile, company overrides, bibliography и attachments.
3. `#show: document.with(...)` передаёт последующие `#include` как тело документа.
4. Facade вызывает application use case.
5. Application разрешает компанию, нормализует и валидирует profile metadata.
6. Renderer применяет foundation, нумерацию, media policy и компонует страницы.
7. При `none` для подписи или печати final renderer использует круг или крест.
8. Typst создаёт `document.pdf`.
### Выбор типа документа
1. Задача VS Code получает `report`, `letter`, `commercial-offer` или `contract`.
2. Tool сохраняет текущие `main.typ` и `chapters/` в `.private/starter-backups/<timestamp>/`.
3. Tool копирует соответствующий публичный пример из `docs/examples/documents/`.
4. Автор проверяет явно перечисленные параметры нового `main.typ`.
### Приватная сборка
1. Пользователь копирует готовую папку `.private` с `settings.typ` и PNG.
2. В `main.typ` значение `use-private-assets` меняется с `false` на `true`.
3. Условный import загружает `.private/settings.typ`.
4. Adapter сопоставляет публичный идентификатор сотрудника с фиксированным именем PNG и применяет private offset.
5. Отсутствующая или отключённая запись возвращает `none`; строка подписи остаётся пустой.
6. Обычная build task и Tinymist preview используют один и тот же `main.typ`.
## Ключевые интерфейсы
```typst
// Единственный пользовательский вход.
#let company-id = "scientia" // scientia | technology | too
#let document-mode = "final" // final | draft | clean-copy
#let use-private-assets = false // true, если скопирована .private
```
```typst
// Порядок и состав глав видны внизу main.typ.
#include "chapters/00-introduction.typ"
#pagebreak()
#include "chapters/10-main.typ"
```
```typst
// Отсутствующая .private не читается при false.
#let private-settings = if use-private-assets {
import "/.private/settings.typ": settings
settings
} else {
empty-private-settings
}
```
```typst
#show: document.with(
company: company,
profile: profiles.report(..),
options: (
mode: document-mode,
watermark: if document-mode == "draft" { "ЧЕРНОВИК" } else { none },
media-policy: if document-mode == "final" { "placeholder" } else { "reserve-space" },
diagnostics: true,
),
)
```
## Публичная и developer-документация
| Слой | Расположение | Содержит | Не содержит |
|------|--------------|----------|-------------|
| Публичный | `README.md`, `docs/` | первый запуск, Git, типы, formatting, папку `.private`, troubleshooting | DDD, ADR, snapshots, migration internals |
| Developer | `.template/development/docs/`, `modules/` | архитектуру, решения, границы и тестирование | обязательный маршрут обычного автора |
Корневой README обязан ссылаться на каждую публичную тему. Developer README доступен одной отдельной ссылкой и не конкурирует с пользовательской навигацией.
## Публичные примеры
| Пример | Обязательное покрытие |
|--------|----------------------|
| Report | титул, stage/volume, executors, includes, рисунки, таблица, formula, references, bibliography, appendix |
| Letter | recipient, исходящий номер, основной текст, attachment list, signer |
| Commercial offer | recipient, subject, price, tax, сроки, payment, scope appendix |
| Contract | parties, representatives, sections, requisites, signing, appendix |
| Formatting | варианты изображений, grid, простые/сложные таблицы, CSV, формулы, labels, lists |
Публичный пример не должен ссылаться на скрытый developer asset. Допустим только импорт фасада `/.template/lib/index.typ`.
## Политика приватных ресурсов
| Ресурс | Git | Поведение |
|--------|-----|-----------|
| Публичные реквизиты и логотипы | tracked | Загружаются из `.template/companies/` |
| Векторные placeholders | tracked | Используются обычной сборкой по умолчанию |
| `docs/examples/private/settings.typ` | tracked | Полный безопасный пример с `enabled: false` |
| `.template/lib/infrastructure/employees.typ` | tracked | ФИО, обычные роли и фиксированные имена PNG |
| `.private/` | ignored | Настройки доступности, offsets, реальные изображения и backups |
Typst 0.15 не предоставляет проверки существования файла. Поэтому отсутствие подписи моделируется отсутствующей записью или `enabled: false`; только включённая запись создаёт private path.
## Технологические решения
| Решение | Выбор | Обоснование |
|---------|-------|-------------|
| Compiler | Typst 0.15.1+ | Проверенный baseline и path type |
| Архитектура | Модульный монолит | Один процесс сборки без лишней инфраструктуры |
| Root entry | Один `main.typ` | Минимум выбора и все параметры в одном месте |
| Режимы | Переменная `document-mode` | Варианты видны комментариями, нет дублирования файлов |
| Public docs | Видимый `docs/` | Автор находит примеры в Explorer |
| Examples | Executable documentation | Код и объяснение не расходятся |
| Private activation | Literal `use-private-assets` + conditional import | Один понятный параметр, clean fork не читает отсутствующий каталог |
| Internal boundary | `.template/` | Реализация и developer docs не мешают автору |
| Testing | Python + Typst + Poppler | Контракты, PDF semantic и визуальный layout |
## Режимы отказа
| Сбой | Влияние | Митигация |
|------|---------|-----------|
| Автор меняет режим не в `main.typ` | Ожидаемый вариант PDF не получается | README и comments показывают единственную переменную |
| Пример использует скрытый developer asset | После очистки development сборка падает | Static path audit и compile всех public examples |
| Выбор типа уничтожает текст | Потеря работы | Timestamp backup до удаления `chapters/` |
| Подпись ещё не получена | Private compile падает при прямом path | Запись отсутствует или `enabled: false`, resolver возвращает `none` |
| Включённая запись не имеет PNG | Ошибка Typst с точным path | Включать запись только после копирования PNG; troubleshooting |
| Реальный файл попадает в Git | Утечка подписи | `.gitignore`, ignored `.private/`, no overwrite tracked placeholders |
| Сложная таблица переполняет страницу | Нарушение layout | Formatting example, stress fixture, repeated header tests |
| Публичная ссылка устарела | Автор теряет маршрут | Automated Markdown link audit |
| Изменение Typst меняет layout | Тихая регрессия | Version gate и snapshot review |
## Вне области видимости v1
- Поддержка старых root-файлов `document.typ`, `draft.typ` и `clean-copy.typ`.
- Автоматическое определение существования private files внутри Typst.
- Хранение реальных подписей и печатей в Git или Git LFS.
- Публикация внутренних VSIX в этом репозитории.
- Юридическая экспертиза договора.
- Научная верификация пользовательского содержания.
- Генерация DOCX.
- Публичная публикация в Typst Universe и open-source лицензирование.
+136
View File
@@ -0,0 +1,136 @@
# План редизайна пользовательской поверхности Scientia
## Обзор
| Фаза | Результат | Статус |
|------|-----------|--------|
| 1 | Зафиксирован baseline до редизайна | [x] |
| 2 | В корне оставлен один настраиваемый `main.typ` | [x] |
| 3 | Публичные инструкции и компилируемые примеры перенесены в `docs/` | [x] |
| 4 | Выбор типа выполняется задачей VS Code, private media — одним параметром | [x] |
| 5 | Полная регрессия и визуальная приёмка | [x] |
## План миграции
**Текущее до редизайна**: пользовательская справка находилась в скрытой `.template/help/`, конфигурация была разделена между `document.typ`, `main.typ`, `draft.typ` и `clean-copy.typ`, а учебные примеры лежали среди developer fixtures.
**Целевое состояние**: автор видит `main.typ`, `chapters/`, `assets/` и `docs/`. Режим задаётся одной переменной. Примеры четырёх типов документов и форматирования видимы, компилируемы и используются как заготовки. Приватная папка подключается одним параметром и содержит настройки индивидуальных подписей.
**Стратегия**: атомарное переключение пользовательского контракта без compatibility-файлов. Обратная совместимость не требуется.
| Фаза | Rollback |
|------|----------|
| 1 | Не требуется: только фиксация baseline |
| 2 | Восстановить предыдущие четыре корневых файла одним change set |
| 3 | Вернуть справку в `.template/help/`, не меняя библиотеку |
| 4 | Отключить задачи и использовать обычную сборку с placeholders |
| 5 | Откатить конкретную правку, повторить compile и visual suites |
---
## Фаза 1 — Зафиксировать baseline
**Цель**: доказать работоспособность библиотеки до изменения пользовательского контракта.
**Результат**: unit, negative, company matrix, semantic и visual suites проходят.
**Трудоёмкость**: S
**Статус**: [x] Готово
### Задачи
- [x] Сохранить compile и visual baseline (→ [Модуль: Тестирование](../modules/testing.md))
- [x] Зафиксировать Typst 0.15.1 (→ [ADR-0001](adr/0001-typst-015.md))
### Тесты
- [x] Unit: domain и numbering
- [x] Интеграционный: четыре профиля и компании
- [x] Визуальный: утверждённые snapshot pages
---
## Фаза 2 — Оставить один `main.typ`
**Цель**: сделать все ежедневные настройки и `#include` видимыми в одном файле.
**Результат**: `document.typ`, `draft.typ` и `clean-copy.typ` отсутствуют; режим выбирается в `main.typ`.
**Трудоёмкость**: M
**Статус**: [x] Готово
### Задачи
- [x] Перенести компанию, режим, metadata и порядок глав в `main.typ` (→ [Рабочее пространство автора](../modules/author-workspace.md))
- [x] Сохранить единый фасад импорта (→ [Публичный фасад](../modules/facade.md))
- [x] Проверить profile/options contract (→ [Документ](../modules/domain-document.md))
- [x] Сохранить границы организаций, сторон и приложений (→ [Организация](../modules/domain-company.md), [Стороны](../modules/domain-parties.md), [Приложения](../modules/domain-attachments.md))
- [x] Проверить application flow и загрузку компаний (→ [Сборка](../modules/application-render.md), [Ресурсы компаний](../modules/infrastructure-assets.md))
### Тесты
- [x] Static: единственный root entrypoint
- [x] Интеграционный: `final`, `draft`, `clean-copy` через profile fixtures
- [x] Ручной: порядок глав меняется только списком `#include`
---
## Фаза 3 — Открыть документацию и примеры
**Цель**: дать автору видимую справку и копируемые примеры без чтения реализации.
**Результат**: `docs/` содержит навигацию, четыре полных документа и каталог оформления.
**Трудоёмкость**: L
**Статус**: [x] Готово
### Задачи
- [x] Разделить публичную и developer-документацию (→ [Публичная документация](../modules/user-documentation.md))
- [x] Сделать примеры источником выбора типа документа (→ [Публичные примеры](../modules/starter-packs.md))
- [x] Показать таблицы, формулы, подписи и media blocks (→ [Компоненты](../modules/components.md), [Основа вёрстки](../modules/presentation-foundation.md))
- [x] Показать ссылки, bibliography и numbering (→ [Ссылки](../modules/references.md), [Нумерация](../modules/numbering.md))
- [x] Подготовить адекватные примеры профилей (→ [Отчёт](../modules/presentation-report.md), [Письмо](../modules/presentation-letter.md), [ТКП](../modules/presentation-commercial-offer.md), [Договор](../modules/presentation-contract.md))
### Тесты
- [x] Интеграционный: пять публичных примеров компилируются
- [x] Semantic: ожидаемые подписи, ссылки, приложения и реквизиты присутствуют
- [x] Ручной: весь `docs/` доступен из корневого README
---
## Фаза 4 — Упростить VS Code и приватные данные
**Цель**: оставить одну build task и безопасно подключать папку `.private` одним параметром.
**Результат**: выбор типа создаёт backup, обычная сборка работает с private media и без неё, отсутствующая подпись не ломает документ.
**Трудоёмкость**: M
**Статус**: [x] Готово
### Задачи
- [x] Оставить в Typewriter единственный `main.typ` и обновить задачи (→ [Рабочая область VS Code](../modules/vscode-workspace.md))
- [x] Добавить публичный справочник сотрудников, фиксированные PNG names и private offsets (→ [Приватные ресурсы](../modules/private-assets.md))
- [x] Использовать conditional import при явном `use-private-assets` (→ [ADR-0010](adr/0010-private-folder-and-employees.md))
### Тесты
- [x] Static: `.vscode/` синхронизируется, `docs/` не скрыт
- [x] Интеграционный: clean fork компилируется без `.private`
- [x] Интеграционный: private compile отображает реальные подписи и offsets
- [x] Интеграционный: `enabled: false` оставляет пустую строку без ошибки
---
## Фаза 5 — Hardening и выпуск
**Цель**: подтвердить отсутствие мёртвых путей, утечек и визуальных дефектов.
**Результат**: полный harness проходит, Markdown-ссылки валидны, приватные ресурсы игнорируются.
**Трудоёмкость**: M
**Статус**: [x] Готово
### Задачи
- [x] Обновить static workspace contract (→ [Тестирование](../modules/testing.md))
- [x] Удалить скрытые дубликаты примеров и notes (→ [Публичная документация](../modules/user-documentation.md))
- [x] Проверить визуально все страницы публичных примеров (→ [ADR-0006](adr/0006-visual-regression.md))
### Тесты
- [x] Полный automated harness
- [x] Аудит Markdown links и legacy paths
- [x] Визуальная проверка contact sheets и проблемных страниц
+63
View File
@@ -0,0 +1,63 @@
# Разработка шаблона Scientia
> Скрытая документация для сопровождающих Typst-библиотеки. Инструкции авторов находятся в публичном каталоге [`docs/`](../../../docs/README.md).
## Текущее устройство
Автор работает с единственным `main.typ`, каталогами `chapters/`, `assets/` и видимой документацией `docs/`. Внутренняя библиотека, профили организаций, тесты и архитектурные решения находятся в `.template/`.
Режим `final`, `draft` или `clean-copy` выбирается переменной внутри `main.typ`. Компилируемые публичные примеры четырёх видов документов и элементов оформления одновременно являются источниками для задачи выбора типа документа.
Обычная сборка использует векторные заглушки. Готовая папка `.private` подключается одним literal-переключателем в `main.typ`; отдельного ZIP installer и отдельной build task нет.
## Команды сопровождающего
```powershell
# Пользовательский документ
typst compile --root . main.typ document.pdf
# Полная регрессия
python .template/development/tests/run-tests.py
```
## Навигация
| Документ | Назначение |
|----------|------------|
| [PLAN.md](PLAN.md) | Завершённые фазы редизайна и проверки |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Структура, компоненты, data flow и режимы отказа |
| [ADR-0001](adr/0001-typst-015.md) | Typst 0.15.1 как baseline |
| [ADR-0002](adr/0002-data-and-assets.md) | Границы данных и ресурсов |
| [ADR-0003](adr/0003-rendering-model.md) | Детерминированная модель выполнения |
| [ADR-0004](adr/0004-error-handling.md) | Ошибки и media fallback |
| [ADR-0005](adr/0005-ddd-boundaries.md) | Направление зависимостей |
| [ADR-0006](adr/0006-visual-regression.md) | Визуальная регрессия |
| [ADR-0007, устарел](adr/0007-author-workspace.md) | Историческое решение о трёх entrypoints |
| [ADR-0008](adr/0008-vscode-onboarding.md) | Версионируемая рабочая область VS Code |
| [ADR-0009](adr/0009-single-main-and-public-docs.md) | Один `main.typ`, публичные примеры и приватная сборка |
| [ADR-0010](adr/0010-private-folder-and-employees.md) | Папка `.private`, справочник сотрудников и частичные подписи |
| [Рабочее пространство автора](../modules/author-workspace.md) | Минимальный корень и один входной файл |
| [Публичные примеры](../modules/starter-packs.md) | Четыре вида документов и каталог оформления |
| [Публичная документация](../modules/user-documentation.md) | Видимый `docs/` для авторов |
| [Рабочая область VS Code](../modules/vscode-workspace.md) | Расширения, задачи и настройки |
| [Приватные ресурсы](../modules/private-assets.md) | Справочник сотрудников, `.private/settings.typ`, offsets и placeholders |
| [Публичный фасад](../modules/facade.md) | Стабильный Typst API |
| [Документ](../modules/domain-document.md) | Профили и режимы выпуска |
| [Организация](../modules/domain-company.md) | Реквизиты и ресурсы компании |
| [Стороны](../modules/domain-parties.md) | Адресаты, подписанты и стороны |
| [Приложения](../modules/domain-attachments.md) | Приложения разных видов документов |
| [Сборка](../modules/application-render.md) | Application orchestration |
| [Ресурсы компаний](../modules/infrastructure-assets.md) | Загрузка публичных профилей и overrides |
| [Основа вёрстки](../modules/presentation-foundation.md) | Tokens и media policy |
| [Отчёт](../modules/presentation-report.md) | Renderer отчёта |
| [Письмо](../modules/presentation-letter.md) | Renderer письма |
| [ТКП](../modules/presentation-commercial-offer.md) | Renderer предложения |
| [Договор](../modules/presentation-contract.md) | Renderer договора |
| [Компоненты](../modules/components.md) | Таблицы, формулы и подписи |
| [Ссылки](../modules/references.md) | Перекрёстные ссылки и библиография |
| [Нумерация](../modules/numbering.md) | Стратегии нумерации |
| [Тестирование](../modules/testing.md) | Static, compile, semantic и visual suites |
## Статус
Редизайн принят. Корневой и публичные примеры компилируются на Typst 0.15.1; полная тестовая матрица является release gate.
@@ -0,0 +1,30 @@
# ADR-0001: Typst 0.15.1 как минимальная платформа
**Дата**: 2026-08-26
**Статус**: Принято
## Контекст
Шаблон уже реализован на Typst и использует его counters, introspection, show rules и PDF-рендер. После обновления окружения доступен Typst 0.15.1, который добавляет несколько bibliographies, тип `path` для передачи project-relative ресурсов и более подробные diagnostics. Одновременно 0.15 меняет baseline некоторых layout-элементов, поэтому обновление должно сопровождаться визуальным аудитом.
Официальные основания: [changelog Typst 0.15.0](https://typst.app/docs/changelog/0.15.0/), [bibliography](https://typst.app/docs/reference/model/bibliography/), [path](https://typst.app/docs/reference/foundations/path/).
## Рассматриваемые варианты
1. **Остаться на Typst 0.14.2** — меньше миграционных рисков сейчас, но нет нативных нескольких bibliographies и нового `path`.
2. **Принять Typst 0.15.1+** — доступны нужные возможности, но требуется новый baseline и контроль будущих обновлений.
3. **Перейти на LaTeX или Word** — большая экосистема, но фактически требует переписать проверенную вёрстку и усложняет программируемые компоненты.
## Решение
Выбрали **Typst 0.15.1 как минимальную поддерживаемую версию**, потому что она уже установлена, поддерживает несколько bibliographies и даёт корректную модель передачи путей между пользовательским проектом и библиотекой.
Каждое обновление Typst выполняется отдельным изменением: сначала полная компиляционная и визуальная матрица, затем принятие новых snapshots.
## Последствия
**Становится проще**: тематические и поглавные библиографии, автономное подключение шаблона, диагностика layout convergence.
**Становится сложнее**: необходимо контролировать версию CLI и визуальные изменения baseline.
**Закрывает дверь на**: поддержку Typst 0.14 и более ранних версий без отдельной compatibility-ветки.
@@ -0,0 +1,37 @@
# ADR-0002: Публичные, пользовательские и приватные ресурсы
**Дата**: 2026-08-31
**Статус**: Принято
> Способ доставки через ZIP заменён папкой `.private` в [ADR-0010](0010-private-folder-and-employees.md). Граница публичных и приватных данных остаётся действующей.
## Контекст
Внутренний шаблон Scientia содержит фирменные реквизиты, логотипы, имена, подписи, печати и материалы конкретного документа. Логотипы, адреса, имена и реквизиты разрешено распространять внутри компании. Реальные подписи и печати нельзя хранить в Git вместе с шаблоном.
Typst не читает ZIP напрямую и не умеет проверить наличие изображения без попытки его загрузить. Поэтому приватный архив должен быть внешним каналом доставки, а отсутствие ресурса должно моделироваться значением `none`.
## Рассматриваемые варианты
1. **Оставить всё в одном tracked-каталоге** — максимально просто, но подписи и печати неизбежно распространяются с каждым форком.
2. **Хранить приватные изображения в Git LFS** — уменьшает основной репозиторий, но не устраняет доступ и историю распространения.
3. **Хранить подписи и печати в отдельном ZIP** — требует извлечения, зато отделяет приватный канал от шаблона.
4. **Не поддерживать реальные изображения вообще** — безопасно, но не покрывает подготовку финальных документов.
## Решение
Выбрали **три класса ресурсов**:
- публичные фирменные данные и логотипы хранятся в `.template/companies/`;
- материалы конкретного документа хранятся в `assets/`;
- реальные подписи и печати поставляются отдельным `private-assets.zip`; проверенная задача извлекает их в `.private/` и включает через `sys.inputs` только на время приватной сборки `main.typ`.
`.private/` и `private-assets*.zip` исключаются через `.gitignore`. В репозитории остаются только нейтральные placeholders: векторный круг для печати и крест для подписи. При значении ресурса `none` renderer использует placeholder; указанный путь обязан существовать.
## Последствия
**Становится проще**: безопасно форкать шаблон, централизованно обновлять публичные реквизиты и собирать документ без приватного архива.
**Становится сложнее**: для финального подписанного PDF нужно получить ZIP, извлечь его и явно указать пути.
**Закрывает дверь на**: хранение настоящих подписей и печатей в обычном Git, Git LFS или visual snapshots.
@@ -0,0 +1,28 @@
# ADR-0003: Синхронная детерминированная модель сборки
**Дата**: 2026-08-26
**Статус**: Принято
## Контекст
Сборка документа выполняется локальным Typst compiler: данные читаются из файлов проекта, затем происходит несколько внутренних итераций layout и создаётся PDF. Внешних сетевых сервисов, конкурентной записи или длительных независимых операций в v1 нет.
Добавление собственной async-модели не ускорит Typst-layout, но усложнит диагностику, воспроизводимость и тестирование.
## Рассматриваемые варианты
1. **Синхронная сборка одного документа** — простая, воспроизводимая и соответствует модели Typst.
2. **Параллельные renderer-профили внутри Typst** — не поддерживаются как управляемая модель и не дают изоляции layout-state.
3. **Внешний асинхронный build-сервис** — полезен для массовой генерации, но избыточен для локального шаблона.
## Решение
Выбрали **синхронную детерминированную сборку одного документа**. Параллельный запуск нескольких независимых fixtures допускается только во внешнем test harness, где каждый процесс получает собственный entry point и output.
## Последствия
**Становится проще**: воспроизводимость, порядок diagnostics, изоляция `state` и расследование visual regressions.
**Становится сложнее**: массовая генерация большого набора документов должна оркестрироваться внешним скриптом.
**Закрывает дверь на**: сетевые и фоновые операции непосредственно внутри шаблона v1.
@@ -0,0 +1,36 @@
# ADR-0004: Ранняя валидация и явные fallback-политики
**Дата**: 2026-08-26
**Статус**: Принято
## Контекст
Ошибки Typst часто проявляются во время layout далеко от места, где пользователь передал неверное значение. Для бизнес-документа особенно опасны тихие fallback: неверная компания, отсутствующая сторона, незаметно пропавшая подпись или citation, не попавшая в список источников.
При этом распространяемый шаблон должен компилироваться без реальных подписей и печатей. Они поставляются отдельным ZIP; PowerShell task проверяет и извлекает архив, после чего запускает `main.typ` с явным `sys.inputs`. Typst 0.15 не предоставляет проверки существования файла без попытки загрузки.
## Рассматриваемые варианты
1. **Полагаться только на diagnostics Typst** — мало кода, но сообщения не отражают доменный путь поля.
2. **Всегда аварийно завершаться при любом отсутствующем ресурсе** — строго, но шаблон нельзя удобно распространять без подписей.
3. **Валидировать domain до layout и явно моделировать необязательные ресурсы** — больше контрактов, зато ошибки предсказуемы.
## Решение
Выбрали **раннюю profile-specific валидацию**. Каждая ошибка называет профиль, путь поля, фактическое значение и ожидаемое ограничение.
Для подписи, печати и необязательных изображений поддерживаются политики:
- `hide` — не показывать ресурс и не резервировать место;
- `placeholder` — показать безопасную графическую заглушку: круг для печати или крест для подписи;
- `reserve-space` — оставить место для ручной подписи или печати.
Политика применяется только если поле равно `none`. Если поле содержит путь, но файл отсутствует, сборка завершается ошибкой.
## Последствия
**Становится проще**: распространение шаблона, поиск причины ошибки и тестирование негативных сценариев.
**Становится сложнее**: каждый профиль обязан определить required/optional поля и defaults.
**Закрывает дверь на**: молчаливое игнорирование неверно указанного пути к производственному ресурсу.
@@ -0,0 +1,37 @@
# ADR-0005: DDD-границы внутри модульного Typst-монолита
**Дата**: 2026-08-26
**Статус**: Принято
## Контекст
Шаблон должен расширяться новыми видами документов, но обычное использование должно оставаться простым. Один монолитный `show` с ветвлением по типу документа быстро свяжет корпоративные данные, file paths, domain-правила и пагинацию. Полноценные микросервисы или отдельные пакеты для каждого bounded context, напротив, избыточны для локальной Typst-библиотеки.
## Рассматриваемые варианты
1. **Одна функция с `if kind == ...`** — минимальный старт, но любое расширение меняет общее ядро и повышает риск регрессии.
2. **Модульный монолит с DDD-границами и profile contract** — изоляция без инфраструктурной сложности.
3. **Отдельный Typst package для каждого вида документа** — сильная физическая изоляция, но дублирование foundation и сложное совместное версионирование.
## Решение
Выбрали **модульный монолит** со слоями Domain → Application и адаптерами Infrastructure/Presentation. Domain не импортирует presentation или infrastructure. Новый вид документа добавляется новым `DocumentProfile`, а не новой веткой в `document()`.
Разрешённое направление зависимостей:
```text
Facade → Application → Domain
│ ▲
├→ Infrastructure
└→ Presentation → Shared Components
```
Presentation и Infrastructure могут создавать domain-значения или читать их, но не изменяют domain-инварианты.
## Последствия
**Становится проще**: добавление договора или другого профиля, независимые fixtures и локализация `show/state`.
**Становится сложнее**: необходимо поддерживать явные contracts и проверять import graph.
**Закрывает дверь на**: доступ domain-модулей к JSON, `image`, `page`, `context` и глобальным renderer-state.
@@ -0,0 +1,32 @@
# ADR-0006: Трёхуровневая стратегия регрессионного тестирования
**Дата**: 2026-08-26
**Статус**: Принято
## Контекст
Шаблон содержит хрупкую пагинацию, сложные таблицы, подписи, формулы, кириллическую нумерацию и show rules. Успешная компиляция не обнаруживает тихий перенос строки, наложение печати или изменение количества страниц. Чистый pixel-perfect diff, в свою очередь, слишком чувствителен к версии renderer и системным шрифтам.
## Рассматриваемые варианты
1. **Проверять только exit code компилятора** — быстро, но не защищает макет.
2. **Использовать только pixel-perfect snapshots** — ловит всё, но создаёт шум при допустимых изменениях окружения.
3. **Совместить unit, semantic и visual проверки** — больше инфраструктуры, зато дефекты классифицируются точнее.
## Решение
Выбрали **три уровня тестов**:
1. Domain unit tests через `typst eval` и `assert`.
2. Compile/semantic tests: exit code, diagnostics, A4, количество страниц, наличие обязательных текстовых маркеров и PDF metadata.
3. Visual regression: rasterize через Poppler, сравнивать контрольные области и полный perceptual diff с документированным порогом.
Snapshots создаются только из синтетического `test-company`; реальные подписи и печати не включаются в публичные тестовые изображения. Новая версия Typst всегда проверяется отдельным прогоном до обновления snapshots.
## Последствия
**Становится проще**: находить как логические, так и визуальные регрессии и безопасно менять отдельные profiles.
**Становится сложнее**: требуется Python/Poppler test runtime и процедура осознанного обновления эталонов.
**Закрывает дверь на**: автоматическое принятие новых snapshots при обычном тестовом запуске.
@@ -0,0 +1,39 @@
# ADR-0007: Минимальный корень и три режима одного документа
**Дата**: 2026-08-31
**Статус**: Устарело — заменено [ADR-0009](0009-single-main-and-public-docs.md)
## Контекст
> Этот ADR сохраняется как история промежуточного решения. Три entrypoint-файла были удалены после проверки на реальных отчётах: пользователю удобнее выбирать режим в одном `main.typ`.
В текущем корне служебные каталоги конкурируют с `chapters/` и `assets/`, а `main.typ` смешивает демонстрационные данные, настройку и сборку. Автору после форка нужен короткий маршрут без изучения DDD-слоёв, тестов и примеров.
Одновременно должны поддерживаться три выпуска одного содержания и четыре вида документов. Обратная совместимость со старыми путями не требуется.
## Рассматриваемые варианты
1. **Оставить служебные каталоги в корне** — удобно разработчику, но перегружает основной сценарий автора.
2. **Удалить тесты и документацию** — очищает корень, но делает шаблон хрупким и плохо сопровождаемым.
3. **Перенести внутреннее устройство в `.template/`** — сохраняет разработку и визуально отделяет её от пользовательских файлов.
4. **Создать отдельный entrypoint для каждого типа и режима** — явно, но приводит минимум к двенадцати корневым файлам и дублированию конфигурации.
## Решение
Выбрали **один скрытый каталог `.template/`**, общий `document.typ` и три корневые точки входа:
- `main.typ` передаёт `mode: "final"`;
- `draft.typ` передаёт `mode: "draft"`;
- `clean-copy.typ` передаёт `mode: "clean-copy"`.
Все entrypoints импортируют `render(mode:)` из `document.typ`. Report, letter, commercial-offer и contract starters реализуют одинаковый контракт, поэтому выбор типа документа не меняет entrypoints.
В starter явно записываются все семантически важные параметры, включая осознанные `none`, `false` и `()`. Низкоуровневые параметры layout остаются внутри библиотеки.
## Последствия
**Становится проще**: первый fork, переключение режима, выбор starter и обновление внутренней реализации.
**Становится сложнее**: `document.typ` является обязательным стабильным контрактом, а каждый starter должен проходить contract-tests.
**Закрывает дверь на**: compatibility-файлы в корне и отдельные копии полной конфигурации для каждого режима.
@@ -0,0 +1,35 @@
# ADR-0008: Версионируемая рабочая область VS Code и обучение автора
**Дата**: 2026-08-31
**Статус**: Принято
## Контекст
Основные пользователи шаблона пишут документы, но могут никогда не работать с кодом, Git и Typst. Устные инструкции и личные настройки редактора не воспроизводятся в новом форке. Одновременно каталог `.vscode/` не должен отвлекать автора от текста.
Часть расширений доступна в публичном Marketplace, а Typst Typewriter и Zotst распространяются внутри компании как локальные VSIX. Запись идентификатора расширения в рекомендациях VS Code не распространяет сам установочный пакет.
## Рассматриваемые варианты
1. **Не хранить настройки редактора** — корень формально проще, но каждый сотрудник вручную повторяет настройку и получает различное поведение.
2. **Настроить всё глобально на рабочих станциях** — удобно на одном компьютере, но не переносится вместе с форком и требует администрирования.
3. **Версионировать `.vscode/` и скрыть его в проводнике** — настройки синхронизируются через Git, оставаясь вне повседневной области автора.
4. **Положить локальные VSIX в шаблон** — обеспечивает автономную установку, но смешивает бинарные пакеты с исходниками и затрудняет централизованное обновление.
## Решение
Выбран вариант **версионировать `.vscode/`, но скрывать его из Explorer**:
- `extensions.json` содержит десять согласованных идентификаторов, включая два внутренних;
- `settings.json` включает автосохранение, языки проверки орфографии, TODO-маркеры и защитные настройки Git;
- `tasks.json` предоставляет обычную и приватную сборку, выбор одного из четырёх публичных примеров и компиляцию учебного каталога;
- внутренние VSIX хранятся в корпоративном хранилище, а README объясняет их установку;
- README и видимый `docs/` обучают Git в терминах истории документа, контрольных точек и параллельных версий.
## Последствия
**Становится проще**: первый запуск, одинаковая среда во всех форках, живой предпросмотр, проверка русского текста и совместная работа через Git.
**Становится сложнее**: изменения `.vscode/` требуют такого же review, как изменения шаблона; сопровождающий должен отдельно публиковать совместимые VSIX.
**Закрывает дверь на**: неявные обязательные глобальные настройки и распространение внутренних бинарных расширений внутри Git-шаблона.
@@ -0,0 +1,35 @@
# ADR-0009: Один main.typ, видимая документация и явная приватная сборка
**Дата**: 2026-08-31
**Статус**: Принято
> Часть решения об отдельной private build task заменена условным import из [ADR-0010](0010-private-folder-and-employees.md). Один `main.typ` и видимая публичная документация остаются действующими.
## Контекст
Проверка шаблона на реальных отчётах показала, что авторы ожидают менять компанию, стадию, режим и список глав в одном файле. Три корневых entrypoint-файла и скрытая пользовательская справка создавали лишний выбор. Одновременно Typst 0.15 не умеет безопасно проверять существование private path.
## Рассматриваемые варианты
1. **Сохранить `document.typ` и три entrypoints** — технически чисто, но пользователь должен понимать разделение ролей четырёх файлов.
2. **Один `main.typ` и tracked private placeholders с заменой** — просто, но реальная подпись становится изменением уже отслеживаемого файла.
3. **Один `main.typ`, public `docs/`, internal placeholders и private task** — минимальная поверхность без риска добавить реальный media в Git.
## Решение
Выбран третий вариант:
- `main.typ` содержит `company-id`, `document-mode`, metadata и `#include`;
- `docs/` видим и содержит executable examples;
- examples одновременно являются источниками задачи выбора типа;
- обычная сборка использует internal placeholders;
- задача приватной сборки проверяет `private-assets.zip`, копирует PNG в `.private/` и передаёт `--input private-assets=true`;
- прежний ADR-0007 считается устаревшим.
## Последствия
**Становится проще**: первый запуск, переключение режима, поиск примеров, выбор типа и выпуск с приватными изображениями.
**Становится сложнее**: main-файлы четырёх примеров частично повторяют setup-код; PowerShell tool становится security boundary для ZIP.
**Закрывает дверь на**: отдельные root entrypoints для режимов, скрытую public-документацию и замену tracked placeholders реальными файлами.
@@ -0,0 +1,26 @@
# ADR-0010: Папка `.private`, справочник сотрудников и частичные подписи
**Дата**: 2026-08-31
**Статус**: Принято
## Контекст
ZIP installer и отдельная build task скрывали второй переключатель private media от автора и не поддерживали реальные фамильные имена PNG. В проектах подписи поступают постепенно, а для каждого изображения уже подобрано индивидуальное вертикальное смещение. Чистый fork при этом обязан собираться без приватного каталога.
## Рассматриваемые варианты
1. **Оставить ZIP и `sys.inputs`** — безопасно для clean fork, но preview и обычная task не совпадают, а схема имён жёстко привязана к ролям.
2. **Автоматически сканировать `.private`** — желаемый UX, но Typst 0.15 не предоставляет file-exists и падает при попытке открыть отсутствующий PNG.
3. **Условный import и явная карта доступности** — один параметр в `main.typ`, отсутствие записи означает пустое место, offsets живут рядом с приватными PNG.
## Решение
Выбран вариант 3. Публичный tracked-справочник хранит идентификатор, ФИО, обычную должность и фиксированное имя PNG. Игнорируемый `.private/settings.typ` хранит доступность и offset. `main.typ` импортирует его только при literal `use-private-assets = true`. Все build/preview пути используют один entrypoint.
## Последствия
**Становится проще**: копировать `.private` целиком, менять роль сотрудника в одной строке, видеть реальные подписи в preview, работать при частично полученных PNG и публиковать clean fork с `false`.
**Становится сложнее**: при получении нового PNG нужно вручную включить запись; включённый, но отсутствующий файл по-прежнему вызывает точную ошибку Typst.
**Закрывает дверь на**: автоматическое определение файлов, role-based имена `responsible.png`, отдельную private build task и ZIP installer.
@@ -0,0 +1,42 @@
# Модуль: Сборка документа
**Ответственность**: выполняет use case «собрать документ» в фиксированном порядке от пользовательских параметров до вызова renderer.
**Расположение**: `.template/lib/application/render-document.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `render-document()` | функция | Оркестрирует resolve company → normalize → validate → create context → render |
| `resolve-profile()` | функция | Проверяет и нормализует встроенный или пользовательский `DocumentProfile` |
| `build-context()` | функция | Создаёт итоговый `DocumentContext` из валидированных частей |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/document.typ` | Контракт профиля, context и render options |
| `.template/lib/domain/company.typ` | Валидацию CompanyProfile |
| `.template/lib/domain/parties.typ` | Валидацию сторон и подписантов |
| `.template/lib/domain/attachments.typ` | Валидацию приложений |
| `.template/lib/infrastructure/company-assets.typ` | Загрузку компании только если передан строковый id |
## Инварианты
- Renderer никогда не вызывается до завершения всех validators.
- Строковый `company` разрешается infrastructure-адаптером; готовый `CompanyProfile` повторно не загружается.
- Порядок normalize и validate одинаков для всех профилей.
- Application не содержит `if profile.id == "report"` или другой profile-specific логики.
- `body` передаётся renderer без изменения пользовательского content.
- Ошибка содержит stage: `resolve-company`, `normalize-profile`, `validate-domain` или `render`.
## Намеренно НЕ обрабатывает
- Page layout и show rules.
- Чтение пользовательского `assets/`.
- Юридическую проверку содержимого.
- Запуск Typst compiler или запись PDF на диск.
## Заметки для агента
> Этот модуль должен оставаться коротким и скучным: его ценность — стабильный pipeline. Если новая возможность требует ветвления по профилю, добавьте её в profile contract или domain service. Infrastructure подключается как адаптер только для удобного строкового company id.
@@ -0,0 +1,42 @@
# Модуль: Рабочее пространство автора
**Ответственность**: предоставляет один понятный входной файл и отделяет пользовательский текст от реализации.
**Расположение**: `main.typ`, `chapters/`, `assets/`, `docs/`
## Публичный интерфейс
| Элемент | Тип | Описание |
|---------|-----|----------|
| `main.typ` | entrypoint | Компания, режим, metadata, ресурсы и порядок `#include` |
| `document-mode` | строка | `final`, `draft` или `clean-copy` |
| `chapters/` | каталог | Текстовые разделы документа |
| `assets/` | каталог | Рисунки, CSV/TSV, bibliography и другие материалы |
| `docs/` | каталог | Публичная справка и компилируемые примеры |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/index.typ` | Единственный Typst facade import |
| `chapters/*.typ` | Явно перечисленное содержимое |
| `assets/*` | Пользовательские paths |
| `.private/settings.typ` | Только при явном `use-private-assets = true` |
## Инварианты
- В корне существует только один Typst entrypoint `main.typ`.
- Все ежедневные параметры и порядок глав видны в одном файле.
- Режимы не представлены отдельными файлами.
- `docs/` не скрыт настройками VS Code.
- Автор не обязан открывать `.template/` для первого PDF.
- Generated PDF и `.private/` не попадают в Git.
## Намеренно НЕ обрабатывает
- Layout renderer и profile validation.
- Разработку нового вида документа.
- Хранение настоящих подписей.
## Заметки для агента
> Корень является пользовательским интерфейсом. Новый обязательный параметр добавляйте в `main.typ` и public examples с вариантом в комментарии. Не создавайте дополнительный entrypoint ради режима сборки.
@@ -0,0 +1,55 @@
# Модуль: Компоненты
**Ответственность**: содержит проверенные переиспользуемые визуальные примитивы, которые не принадлежат одному профилю документа.
**Расположение**: `.template/lib/presentation/components.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `corp-table()` | функция | Корпоративная таблица со сложной шапкой, spans и продолжениями |
| `formula()` | функция | Блочная формула, совместимая с профильной нумерацией |
| `info-block()` | функция | Цветная информационная плашка для замечаний и пояснений |
| `signature-block()` | функция | Один подписант с media policy |
| `multi-party-signing()` | функция | Подписание несколькими сторонами |
| `approval-block()` | функция | Блок утверждения отчёта |
| `company-footer()` | функция | Контакты и реквизиты организации в footer |
| `letter-header()` | функция | Общая геометрия исходящего письма и ТКП |
| `attachment-list()` | функция | Перечень приложений к письму или ТКП |
| `requisites-table()` | функция | Реквизиты одной или нескольких сторон |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/company.typ` | Нормализованные реквизиты и brand |
| `.template/lib/domain/parties.typ` | Signer, Approval и Party |
| `.template/lib/domain/attachments.typ` | AttachmentSet для перечней |
| `.template/lib/presentation/foundation.typ` | Design tokens и `render-media-slot()` |
| `.template/lib/shared/numbering.typ` | Общие numbering functions |
## Инварианты
- Компонент не читает JSON и не конструирует пользовательский path.
- Компонент не определяет global page settings.
- `corp-table` сохраняет поддержку auto/multirow headers, rowspan/colspan, repeat header и continuation label.
- `corp-table.list-layout` локально задаёт геометрию списков в ячейках; `auto` сохраняет оформление документа, а параметры конкретного списка имеют приоритет.
- `row_breakable: true` действительно передаётся в `table.cell(breakable:)`; режим проверяется fixture с одной строкой, которая продолжается на трёх страницах.
- Центрирование блока `corp-table` не наследуется текстом ячеек: шапка по умолчанию центрирована, тело выровнено влево; явный `align` имеет приоритет.
- Блок подписи сохраняет эталонную геометрию для обычных реквизитов и переключается на ограниченные равные боковые колонки при длинных наименованиях.
- Письмо и ТКП используют один эталонный footer; на страницах приложений footer скрывается.
- Обязательный positional `body` идёт первым в функциях, используемых через `.with`, если это требуется Typst.
- Компонент принимает domain-значение и layout options отдельно.
- Любая работа с изображением проходит через media slot или проверку `resource != none`.
- Компоненты не импортируют profile renderers.
## Намеренно НЕ обрабатывает
- Полный жизненный цикл документа.
- Profile-specific обязательность полей.
- Выбор компании по id.
- Сброс глобальных counters между главами.
## Заметки для агента
> `corp_table` считается особо хрупким: не переписывайте рекурсивное определение header rows без unit и visual fixtures на rowspan/colspan. Общность компонента доказывается использованием хотя бы в двух профилях; иначе оставьте его внутри конкретного renderer. Layout offsets изображений должны быть параметрами компонента или design tokens, но не данными Signer.
@@ -0,0 +1,41 @@
# Модуль: Приложения
**Ответственность**: задаёт идентичность, порядок, заголовки и нумерацию приложений независимо от профиля и конкретной вёрстки.
**Расположение**: `.template/lib/domain/attachments.typ`
Модель используется письмами, ТКП и договорами. Отчётные приложения подключаются отдельными файлами через параметр `report.appendices`, потому что их заголовок и label должны находиться внутри авторского файла.
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `attachment()` | конструктор dictionary | Создаёт приложение с `id`, `title`, `subtitle`, `body`, `numbering` |
| `attachment-set()` | конструктор dictionary | Нормализует массив приложений и общую политику нумерации |
| `validate-attachments()` | функция | Проверяет уникальность ids, номеров и допустимость body |
| `attachment-label()` | чистая функция | Формирует семантическое обозначение без layout |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/shared/numbering.typ` | Стратегии арабской и кириллической нумерации как чистые функции |
## Инварианты
- `id` приложения уникален и пригоден для label.
- Порядок массива является порядком документа, если пользователь явно не задал sort key.
- Номер не хранится одновременно как вычисляемый и вручную заданный без явной override-policy.
- `body` является content или функцией, которую renderer вызывает в локальном контексте.
- Заголовок обязателен; subtitle необязателен.
- Приложение не меняет global counter вне вызова своего renderer.
## Намеренно НЕ обрабатывает
- Размещение pagebreak и заголовка приложения.
- Физическое объединение внешних PDF.
- Подсчёт страниц вложения до компиляции.
- Profile-specific текст «Приложение к договору».
## Заметки для агента
> Старые `make_appendices` и `appendix-header` решают presentation-задачи и не переносятся в domain. Domain должен одинаково поддерживать кириллические приложения отчёта, цифровые приложения ТКП и именованные приложения договора. Renderer выбирает display policy на основе profile metadata.
@@ -0,0 +1,39 @@
# Модуль: Организация
**Ответственность**: описывает единый нормализованный профиль юридического лица и его фирменных ресурсов.
**Расположение**: `.template/lib/domain/company.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `company-profile()` | конструктор dictionary | Создаёт профиль из секций `legal`, `contacts`, `banking`, `brand`, `director`, `resources` |
| `validate-company()` | функция | Проверяет обязательные реквизиты и типы необязательных полей |
| `company-display-name()` | чистая функция | Возвращает полное или краткое наименование по политике профиля |
| `company-resource()` | чистая функция | Возвращает нормализованное значение ресурса из уже построенного profile |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| Нет | Domain не читает JSON и не строит пути |
## Инварианты
- `id`, полное наименование и юрисдикция заданы.
- ИНН/КПП/ОГРН и БИН/КБЕ хранятся как строки, чтобы не терять ведущие нули и формат.
- Контактные и банковские поля имеют единые ключи для всех компаний; неприменимое поле равно `none`.
- Brand color нормализован до color до передачи renderer.
- Логотип, подпись и печать имеют значение `none`, `path` или готовый content; произвольная строка после infrastructure-нормализации не допускается.
- Director содержит должность и ФИО; ресурсы подписи и печати хранятся отдельно и не задают координаты на странице.
## Намеренно НЕ обрабатывает
- Поиск компании по содержимому документа.
- Чтение `.template/companies/` и `.private/`.
- Отрисовку логотипа, подписи, печати и реквизитов.
- Валидацию законодательства конкретной юрисдикции.
## Заметки для агента
> Существующие JSON-файлы отличаются по набору полей. Нормализуйте их через infrastructure, не добавляйте условные ключи в renderer. Для РФ и Казахстана используйте общую структуру с `none` для неприменимых идентификаторов. Не переносите пользовательские изображения в CompanyProfile.
@@ -0,0 +1,40 @@
# Модуль: Документ
**Ответственность**: владеет общей моделью документа, контрактом расширяемого профиля и режимами выпуска независимо от Typst-вёрстки.
**Расположение**: `.template/lib/domain/document.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `document-context()` | конструктор dictionary | Создаёт полный неизменяемый контекст после нормализации и валидации |
| `document-profile()` | конструктор dictionary | Создаёт контракт профиля с `id`, `metadata`, `normalize`, `validate`, `render` |
| `render-options()` | конструктор dictionary | Нормализует режим выпуска, watermark и media policy |
| `validate-profile-contract()` | функция | Проверяет, что расширение содержит все обязательные функции и поля |
| `merge-known()` | чистая функция | Объединяет defaults с пользовательскими значениями по явным правилам |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| Нет | Модуль не импортирует infrastructure или presentation |
## Инварианты
- `DocumentProfile.id` — непустая стабильная строка.
- `normalize`, `validate` и `render` являются functions.
- Режим выпуска принадлежит конечному набору `final`, `draft`, `clean-copy`.
- `DocumentContext` создаётся только после успешной валидации company, profile metadata, parties и attachments.
- Domain-значения не содержат вызовов `page`, `image`, `place`, `context`, `query` или глобального `state`.
- Merge не принимает неизвестные ключи молча: профиль либо объявляет extension bucket, либо возвращает ошибку.
## Намеренно НЕ обрабатывает
- Загрузку компании из JSON.
- Пагинацию и выбор шрифта.
- Физическое наличие файлов.
- Состав обязательных полей конкретного отчёта или договора.
## Заметки для агента
> `DocumentProfile` — основной extension contract. Не превращайте его в строковый enum со switch в application. Renderer-function является портом presentation, переданным профилем. Для простого пользователя profile constructors скрывают этот контракт; вручную он нужен только разработчику нового вида документа.
@@ -0,0 +1,40 @@
# Модуль: Стороны
**Ответственность**: моделирует адресатов, стороны договора, представителей, подписантов и блоки утверждения независимо от их размещения.
**Расположение**: `.template/lib/domain/parties.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `party()` | конструктор dictionary | Создаёт организацию или физическое лицо в роли стороны документа |
| `recipient()` | конструктор dictionary | Создаёт адресата делового письма или ТКП |
| `signer()` | конструктор dictionary | Создаёт подписанта с должностью, ФИО, основанием и ресурсом подписи |
| `approval()` | конструктор dictionary | Создаёт данные блока утверждения отчёта |
| `validate-parties()` | функция | Проверяет уникальность ролей и профильные обязательные поля |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/company.typ` | Может принять нормализованный `CompanyProfile` как сторону, не загружая его |
## Инварианты
- Каждая сторона имеет уникальный `id` внутри документа и непустую `role`.
- Recipient допускает отдельно организацию, должность и имя; пустые строки нормализуются в `none`.
- Signer хранит семантические данные и ресурс, но не layout offsets.
- Contract-party содержит реквизиты либо ссылку на `CompanyProfile`, но не оба источника с конфликтующими значениями.
- Approval date и document date являются разными полями и не подменяют друг друга.
- Обязательность подписи определяется профилем и режимом выпуска, а не самим `Signer`.
## Намеренно НЕ обрабатывает
- Координаты и размеры изображений подписи.
- Текст преамбулы договора.
- Склонение ФИО и должностей.
- Загрузку реквизитов из файлов.
## Заметки для агента
> Письмо, ТКП и договор должны использовать одинаковые базовые Party/Signer, но разные validators. Не добавляйте коммерческие поля в recipient и report-specific approval в party. Если понадобится склонение, создайте отдельный domain service, а не набор условий внутри renderer.
+48
View File
@@ -0,0 +1,48 @@
# Модуль: Публичный фасад
**Ответственность**: предоставляет `main.typ` и публичным примерам стабильный API, скрывая внутренние DDD-слои и файловую структуру.
**Расположение**: `.template/lib/index.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `document()` | show-функция | Собирает документ из явно переданных `company`, `profile`, `options` и `body` |
| `profiles` | module namespace | Конструкторы `report`, `letter`, `commercial_offer`, `contract` |
| `components` | module namespace | Поддерживаемые визуальные компоненты для пользовательского content |
| `references` | module namespace | `vref`, `vrefs`, `eqref` и bibliography helpers |
| `load-company()` | функция | Загружает публичный профиль и применяет явные resource overrides |
| `report-executor()` | функция | Разрешает сотрудника, роль, private PNG и offset |
| `private-company-media()` | функция | Разрешает private подпись и печать организации |
| `document-profile()` | конструктор dictionary | Extension contract для нового вида документа |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/application/render-document.typ` | Единственный application use case |
| `.template/lib/presentation/profiles/*.typ` | Публичные profile constructors |
| `.template/lib/presentation/components.typ` | Разрешённый пользовательский набор компонентов |
| `.template/lib/presentation/references.typ` | Публичные helpers ссылок |
| `.template/lib/infrastructure/company-assets.typ` | Загрузка профилей компаний |
| `.template/lib/infrastructure/employees.typ` | Справочник сотрудников и private settings adapter |
## Инварианты
- `main.typ` и public examples используют один import фасада и не знают внутренних путей.
- В фасаде нет чтения пользовательских глав, page layout и profile-specific ветвлений.
- Добавление нового профиля не меняет сигнатуру `document()`.
- Resource override принимает явные `none`, `path` или content и не сканирует `.private/`.
- В namespaces экспортируются только документированные символы.
- В diagnostics используется бренд Scientia.
## Намеренно НЕ обрабатывает
- Включение пользовательских файлов из `chapters/`.
- Создание пользовательских paths к `assets/`.
- Выбор типа документа и режима выпуска.
- Юридическую или смысловую проверку текста.
## Заметки для агента
> Фасад является границей между пользовательским `main.typ` и библиотекой. Если public example импортирует domain, infrastructure или конкретный renderer напрямую, фасад недостаточен. Обратная совместимость со старым корневым `template.typ` не требуется; не создавайте alias в корне.
@@ -0,0 +1,41 @@
# Модуль: Ресурсы компаний
**Ответственность**: читает публичные данные `.template/companies/`, нормализует JSON, создаёт устойчивые Typst paths и применяет явные overrides подписи и печати.
**Расположение**: `.template/lib/infrastructure/company-assets.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `load-company()` | функция | Загружает профиль по разрешённому id и принимает `logo`, `signature`, `stamp` overrides |
| `available-companies()` | функция | Возвращает детерминированный список встроенных ids |
| `merge-resources()` | чистая функция | Применяет только явно переданные resource overrides |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/company.typ` | `company-profile()` и `validate-company()` |
| `.template/companies/*/data.json` | Публичные реквизиты организаций |
| `.template/companies/*/logo.*` | Публичные логотипы |
| `.template/lib/assets/placeholders/` | Безопасные круг и крест |
## Инварианты
- Адаптер не сканирует `assets/` или `.private/`.
- Относительные внутренние paths создаются в infrastructure-файле.
- Private paths организаций создаются адаптером сотрудников из явной настройки и передаются как готовые `path`.
- Идентификатор компании выбирается из явного registry.
- Публичные JSON не содержат путей к реальным подписям и печатям.
- `none` сохраняется как отсутствие ресурса и обрабатывается media policy.
- Указанный override не подменяется placeholder при ошибке загрузки.
## Намеренно НЕ обрабатывает
- Layout логотипа, подписи или печати.
- Поиск файлов по имени.
- Сетевую загрузку реквизитов.
## Заметки для агента
> Typst `path` сохраняет контекст файла, в котором создан. Public company paths создавайте здесь, а absolute `.private/` paths — только в private/employee adapter. Значения `auto` и `none` должны различаться: `auto` означает взять публичное значение профиля, `none` — осознанно применить media policy.
+44
View File
@@ -0,0 +1,44 @@
# Модуль: Настраиваемые списки
**Ответственность**: формирует многоуровневые номера и маркеры, сохраняя нативную вёрстку `enum`.
**Расположение**: `.template/lib/presentation/lists.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `bullet-list()` | function | Локально применяет геометрию к стандартному маркированному списку |
| `numbered-list()` | function | Локально применяет схему и геометрию к стандартному нумерованному списку |
| `list-scheme()` | constructor | Создаёт проверенную конфигурацию уровней, разделителей и окончаний |
| `list-level()` | constructor | Описывает нестандартный уровень, включая prefix, suffix и width |
| `list-numbering()` | function factory | Возвращает функцию нумерации для прямого использования в `enum` |
| `list-schemes` | dictionary | Хранит публичные готовые схемы |
## Инварианты
- Модуль не заменяет `enum`: переносы страниц, вложенность и многоабзацные элементы остаются ответственностью Typst.
- Внутренне `enum.full` всегда включён, чтобы форматтер знал глубину; показ родительских уровней определяет `scheme.full`.
- Если массив levels, separators или suffixes короче глубины, повторяется его последнее значение.
- Неизвестная именованная схема вызывает понятную ошибку и не подменяется схемой по умолчанию.
- Режимы `normal`, `compact` и `flush` меняют только геометрию; `auto` наследует окружающий стиль.
- Локальные параметры `bullet-list` и `numbered-list` не должны менять списки за пределами переданного body.
## Поддерживаемые обозначения
- арабские числа: `1`;
- арабские числа с ведущим нулём: `01` или `list-level("1", width: N)`;
- римские числа: `I`, `i`;
- латинские буквы: `A`, `a`;
- кириллица по ГОСТ: `А`, `а`;
- любой строковый или content-маркер;
- пользовательская функция `value => content`.
## Намеренно НЕ обрабатывает
- собственную раскладку строк и переносы страниц;
- скрытое глобальное продолжение счётчика между несвязанными списками;
- автоматический выбор схемы по содержимому текста.
## Заметки для агента
> Не заменяйте нативный `enum` ручной сеткой или таблицей. Это ухудшит переносы, семантику документа и поддержку многоабзацных пунктов. Новые возможности добавляйте через форматирование массива родительских номеров и локальные set/show rules.
@@ -0,0 +1,40 @@
# Модуль: Нумерация
**Ответственность**: предоставляет чистые стратегии арабской, многоуровневой и кириллической нумерации без управления counters конкретного профиля.
**Расположение**: `.template/lib/shared/numbering.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `cyrillic-numbering()` | чистая функция | Преобразует положительный номер в допустимую заглавную кириллическую букву |
| `cyrillic-lower-numbering()` | чистая функция | Формирует строчную кириллическую часть списка |
| `num-11()` | чистая функция | Формирует многоуровневый формат `1.1.` |
| `num-1a()` | чистая функция | Чередует цифровые и кириллические уровни с наследованием |
| `num-1-a()` | чистая функция | Чередует уровни без полного наследования родителей |
| `attachment-numbering()` | чистая функция | Выбирает арабское или кириллическое обозначение из policy |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| Нет | Модуль состоит из чистых функций и неизменяемых массивов символов |
## Инварианты
- Исключённые ГОСТ-буквы не используются в кириллической последовательности.
- Ноль и отрицательные значения не маскируются неявным fallback, несовместимым с будущими версиями Typst.
- Функции не читают counters самостоятельно и форматируют только переданные числа.
- Один и тот же вход всегда возвращает одинаковую строку.
- Profile renderer управляет reset и scope counters, а не shared-модуль.
## Намеренно НЕ обрабатывает
- Сброс counters при новой главе.
- Выбор numbering policy конкретного документа.
- Формат заголовка приложения и supplement.
- Локализацию на другие языки.
## Заметки для агента
> Typst 0.15 предупреждает о fallback для numbering systems, которые не умеют отображать ноль. Добавьте явные негативные unit tests. Сохраните существующие варианты `num_11`, `num_1a`, `num_1_a` семантически, но публичные имена можно унифицировать, поскольку обратная совместимость не требуется.
@@ -0,0 +1,43 @@
# Модуль: Технико-коммерческое предложение
**Ответственность**: формирует ТКП как самостоятельный профиль с предметом, ценой, сроками, коммерческими условиями и приложениями.
**Расположение**: `.template/lib/presentation/profiles/commercial-offer.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `commercial-offer-profile()` | конструктор `DocumentProfile` | Принимает адресата, предмет, стоимость, валюту, сроки и validity |
| `render-commercial-offer()` | renderer-функция | Собирает шапку, резюме предложения, body, условия, подпись и приложения |
| `commercial-terms()` | domain-normalizer | Нормализует цену, НДС, валюту, срок и порядок оплаты |
| `commercial-offer-validators()` | массив functions | Проверяет предмет и обязательные коммерческие поля |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/document.typ` | `DocumentProfile` и context |
| `.template/lib/domain/parties.typ` | Recipient и Signer |
| `.template/lib/domain/attachments.typ` | Календарный план, ТЗ и другие приложения |
| `.template/lib/presentation/foundation.typ` | Typography, page и media policy |
| `.template/lib/presentation/components.typ` | Letter shell, money/date blocks, company footer, signature block |
## Инварианты
- ТКП имеет собственный `profile.id` и renderer, а не boolean-режим письма.
- Стоимость хранится структурированно: amount, currency, tax note; renderer не разбирает свободную строку.
- Срок выполнения и срок действия предложения являются разными полями.
- Приложения используют общий AttachmentSet и могут иметь цифровую нумерацию.
- Коммерческие defaults принадлежат constructor, но пользователь может заменить текстовые формулировки через content slots.
- Renderer не импортирует внешний `G:/TYPST/TKP` и не читает его config.
## Намеренно НЕ обрабатывает
- Расчёт стоимости из сметы.
- Юридическую проверку налоговой формулировки.
- Автоматическое превращение ТКП в договор.
- Специфические главы научного отчёта.
## Заметки для агента
> Используйте TKP только как контекст сценариев и визуальную подсказку. Не копируйте монолитный facade. Если уникальная иконка действительно нужна, перенесите её в `.template/lib/assets/icons/` и дайте ей семантическое имя. Таблицы календарного плана должны использовать общий `corp-table`, а не локальную реализацию.
@@ -0,0 +1,44 @@
# Модуль: Договор
**Ответственность**: предоставляет расширяемый каркас договора с преамбулой, произвольными разделами, сторонами, реквизитами, подписанием и приложениями.
**Расположение**: `.template/lib/presentation/profiles/contract.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `contract-profile()` | конструктор `DocumentProfile` | Принимает номер, дату, место, название, стороны и section options |
| `render-contract()` | renderer-функция | Собирает header, preamble slot, body sections, requisites, signing и attachments |
| `contract-section()` | конструктор dictionary | Описывает нумерованный или именованный раздел с body |
| `contract-validators()` | массив functions | Проверяет стороны, номера, даты и уникальность sections |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/document.typ` | `DocumentProfile` и context |
| `.template/lib/domain/parties.typ` | Contract parties, representatives и signers |
| `.template/lib/domain/attachments.typ` | Приложения и спецификации |
| `.template/lib/presentation/foundation.typ` | Base typography и signing-copy mode |
| `.template/lib/presentation/components.typ` | Requisites table, multi-party signing, tables и numbering primitives |
## Инварианты
- В v1 поддерживается не менее двух сторон; модель не зашита строго на роли «Заказчик/Исполнитель».
- Section id и display number уникальны.
- Юридический текст sections остаётся пользовательским content.
- Preamble можно передать content или собрать из сторон через явный helper; автоматический текст не является юридической гарантией.
- Requisites берутся из Party/CompanyProfile, а не дублируются внутри renderer.
- Signing-copy может резервировать место без реальных изображений подписи и печати.
- Приложения используют тот же AttachmentSet, что отчёт и ТКП.
## Намеренно НЕ обрабатывает
- Юридическую достаточность и актуальность условий.
- Электронную подпись и криптографию.
- Согласование версий договора и tracked changes.
- Автоматическую генерацию актов, счетов или УПД.
## Заметки для агента
> Не превращайте contract-profile в библиотеку юридических формулировок. Профиль отвечает за типографику и композицию. Чтобы будущие лицензионные, сервисные или смешанные договоры не требовали изменения renderer, sections должны быть открытым массивом с устойчивой numbering policy.
@@ -0,0 +1,41 @@
# Модуль: Основа вёрстки
**Ответственность**: предоставляет общие design tokens, режимы выпуска и безопасные presentation-примитивы без правил конкретного вида документа.
**Расположение**: `.template/lib/presentation/foundation.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `design-tokens()` | конструктор dictionary | Шрифты, размеры, цвета, интервалы и стандартные поля страницы |
| `apply-foundation()` | show-функция | Применяет локальные общие text/par/page defaults к body renderer |
| `render-media-slot()` | функция | Отображает ресурс по policy `hide`, `placeholder`, `reserve-space` |
| `render-mode()` | чистая функция | Нормализует `final`, `draft`, `clean-copy` |
| `watermark-layer()` | функция | Создаёт слой watermark без изменения domain данных |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/document.typ` | `RenderOptions` и режим выпуска |
| `.template/lib/domain/company.typ` | Brand color и нормализованные ресурсы |
## Инварианты
- Foundation не знает, является документ отчётом, письмом, ТКП или договором.
- Общие defaults локальны body, не протекают в следующий независимый renderer.
- `render-media-slot()` никогда не вызывает `image(none)`.
- Размер placeholder и reserve-space задаётся вызывающим компонентом, чтобы не ломать профильную геометрию.
- Watermark не влияет на layout flow и счётчики.
- Внутри module scope не создаётся state, общий для нескольких документов.
## Намеренно НЕ обрабатывает
- Титульный лист отчёта.
- Шапку письма и реквизиты договора.
- Нумерацию приложений конкретного профиля.
- Загрузку файлов и JSON.
## Заметки для агента
> Typst 0.15 изменил baseline у `box` и `block`; любые правки foundation требуют visual regression всех профилей. Не переносите сюда profile-specific отступ только потому, что он встречается в двух документах: сначала проверьте, является ли это действительно общим design token.
@@ -0,0 +1,43 @@
# Модуль: Деловое письмо
**Ответственность**: формирует исходящее деловое письмо с адресатом, регистрационными данными, темой, основным текстом и подписанием.
**Расположение**: `.template/lib/presentation/profiles/letter.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `letter-profile()` | конструктор `DocumentProfile` | Принимает дату, исходящий номер, адресата, тему и signing options |
| `render-letter()` | renderer-функция | Собирает фирменную шапку, body, подпись и footer |
| `letter-metadata()` | чистая функция | Нормализует регистрационные поля и тему |
| `letter-validators()` | массив functions | Проверяет адресата, дату и подписанта |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/document.typ` | `DocumentProfile` и render options |
| `.template/lib/domain/parties.typ` | Recipient и Signer |
| `.template/lib/domain/attachments.typ` | Перечень приложений к письму |
| `.template/lib/presentation/foundation.typ` | Общие tokens и media policy |
| `.template/lib/presentation/components.typ` | Letter header, company footer, attachment list, signature block |
## Инварианты
- Letter profile не содержит коммерческой стоимости, графика работ или offer validity.
- Recipient может быть частично заполнен, но validator требует хотя бы организацию или ФИО.
- Исходящий номер и дата отображаются единым регистрационным блоком.
- Footer включается profile option и не исчезает из-за состояния приложения другого документа.
- Подпись, печать и место для ручного подписания obey media policy.
- Renderer не импортирует report или commercial-offer profile.
## Намеренно НЕ обрабатывает
- Коммерческие условия ТКП.
- Титул и содержание отчёта.
- Разделы договора.
- Регистрацию письма во внешней системе.
## Заметки для агента
> Внешний проект TKP содержит полезный визуальный референс шапки и footer, но его global `comp-data` и `in-appendix` не переносятся. Состояние footer, если оно понадобится, должно принадлежать только текущему renderer и сбрасываться внутри него. Общая геометрия письма выносится в components, чтобы ТКП переиспользовал её без импорта `render-letter()`.
@@ -0,0 +1,48 @@
# Модуль: Отчёт
**Ответственность**: реализует профиль научно-технического отчёта как стартовую заготовку, не влияя на деловые документы.
**Расположение**: `.template/lib/presentation/profiles/report.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `report-profile()` | конструктор `DocumentProfile` | Принимает метаданные отчёта и безопасные defaults |
| `render-report()` | renderer-функция | Собирает титул, служебные страницы, body, библиографии и приложения |
| `report-metadata()` | чистая функция | Нормализует название, тему, договор, этап, том, год и режим исследования |
| `report-validators()` | массив functions | Проверяет обязательные поля и совместимость опций |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `.template/lib/domain/document.typ` | `DocumentProfile`, `DocumentContext` |
| `.template/lib/domain/parties.typ` | Approval и список исполнителей |
| `.template/lib/appendices.typ` | Локальный контекст заголовков, страниц и нумерации файлов приложений |
| `.template/lib/presentation/foundation.typ` | Общую страницу, typography и render mode |
| `.template/lib/presentation/components.typ` | Title primitives, signature rows, tables, formula |
| `.template/lib/presentation/references.typ` | Cross-references и bibliography sections |
| `.template/lib/shared/numbering.typ` | Нумерацию глав, фигур, формул и приложений |
## Инварианты
- Все report-specific `set/show/state` локальны `render-report()`.
- Титульный лист, список исполнителей и содержание независимо включаются параметрами `show_title_page`, `show_executors` и `show_outline`.
- `draft/clean-copy` не загружает изображения подписей и печатей при соответствующей media policy.
- Отключённая служебная страница не оставляет пустого листа или лишнего разрыва; первая отображаемая страница получает номер 1.
- Counters фигур, таблиц и формул сбрасываются только в границах отчётной главы.
- `appendices` содержит только уникальные `path`; название и label принадлежат первому заголовку подключённого файла.
- Приложения используют собственную стратегию numbering без изменения других profiles; `attachment()` остаётся моделью писем и договоров.
- Bibliographies представлены массивом секций; renderer не ограничивает их количество одним `refs.bib`.
- Пользовательский body не изменяется и размещается после служебных страниц.
## Намеренно НЕ обрабатывает
- Адресата исходящего письма.
- Стоимость и срок действия ТКП.
- Стороны и реквизиты договора.
- Загрузку компании и пользовательских файлов.
## Заметки для агента
> Legacy renderer в `.template/lib/report.typ` является функциональной спецификацией, но его API можно менять. Переносите секции по одной и после каждой сравнивайте snapshots. Сохраните проверенные решения вокруг heading gaps, paragraph indent, continuation tables и executor signatures, пока тест не докажет, что упрощение безопасно.
@@ -0,0 +1,33 @@
# Модуль: Приватные ресурсы и сотрудники
**Ответственность**: подключает игнорируемые Git подписи и печати через один переключатель, хранит публичный справочник сотрудников и оставляет пустое место при недоступной подписи.
**Расположение**: `.template/lib/infrastructure/employees.typ`, `.template/lib/assets/placeholders/`, `.private/`, `docs/examples/private/settings.typ`
## Публичный интерфейс
| Элемент | Назначение |
|---------|------------|
| `employee-directory` | ФИО, обычные должности и имена PNG |
| `empty-private-settings` | Безопасная конфигурация без private paths |
| `private-company-media()` | Возвращает подпись и печать выбранной организации либо `none` |
| `report-executor()` | Строит legacy-compatible tuple исполнителя с необязательной подписью |
| `.private/settings.typ` | Локальная доступность изображений и индивидуальные offsets |
## Инварианты
- При `use-private-assets = false` условный import не читает `.private/settings.typ`.
- Включённая запись ссылается только на фиксированное имя из публичного справочника.
- Отсутствующая или отключённая запись возвращает `none`, поэтому строка подписи остаётся пустой.
- Реальные файлы не заменяют tracked placeholders.
- Каталог `.private/` целиком игнорируется Git.
- Обычная сборка и private-сборка используют одну VS Code task.
## Намеренно НЕ обрабатывает
- Доставку и шифрование папки `.private`.
- Проверку подлинности подписи.
- Автоматическую проверку наличия файла внутри Typst 0.15.
## Заметки для агента
> Typst не предоставляет безопасный file-exists. Поэтому при ещё не полученной подписи запись должна отсутствовать или иметь `enabled: false`. При добавлении сотрудника синхронно обновите directory, безопасный пример и публичную таблицу.
@@ -0,0 +1,49 @@
# Модуль: Ссылки и библиографии
**Ответственность**: предоставляет русскоязычные перекрёстные ссылки и модель одной или нескольких библиографических секций Typst 0.15.
**Расположение**: `.template/lib/domain/references.typ`, `.template/lib/presentation/references.typ`
## Публичный интерфейс
| Символ | Тип | Описание |
|--------|-----|----------|
| `vref()` | contextual function | Форматирует одну ссылку с падежом для рисунка, таблицы, формулы, раздела или приложения |
| `vrefs()` | contextual function | Форматирует массив однородных labels с корректным соединением |
| `eqref()` | contextual function | Создаёт ссылку на формулу без лишнего supplement |
| `bibliography-section()` | domain constructor | Описывает sources, title, style, target, group и политику переноса |
| `render-bibliographies()` | function | Размещает произвольное число секций через нативный `bibliography()` |
| `validate-bibliographies()` | function | Проверяет ids, sources, target/group и наличие default coverage policy |
`vref()` и `vrefs()` по умолчанию используют предложный падеж и прописную первую букву названия объекта. Короткие коды — `"и"`, `"р"`, `"д"`, `"в"`, `"т"`, `"п"`; прежние сокращения и полные русские названия нормализуются во внутренние ключи `"имен"`, `"род"`, `"дат"`, `"вин"`, `"тв"`, `"предл"`. Строчная форма включается явно через `capitalized: false`.
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| Typst `query`, `selector`, `ref`, `cite` | Разрешение labels и citations |
| Typst 0.15 `bibliography(target:, group:)` | Несколько списков источников и управление нумерацией |
| `.template/lib/domain/document.typ` | Profile metadata и render options |
## Инварианты
- Cross-reference label и bibliography citation являются разными типами использования и не смешиваются в одной функции.
- `vref` всегда обрабатывает состояние `element == none`, потому что Typst может ещё не обнаружить элемент.
- `vref` поддерживает `имен`, `род`, `дат`, `вин`, `тв`, `предл`; неизвестный падеж является ошибкой, а не тихим fallback.
- `vrefs` объединяет однородные labels под одной формой множественного числа (`рисунках 1 и 2`, `приложениях А и Б`), смешанные типы форматирует поэлементно.
- Заголовок с supplement `Приложение` классифицируется отдельно от обычного раздела и поддерживает все шесть падежей.
- Каждая bibliography section имеет стабильный id и хотя бы один source.
- Последующие секции по умолчанию начинаются с новой страницы (`page_break: true`), поэтому их заголовки и записи не могут наложиться; компактный режим включается явно.
- `target` передаётся как `auto`, label, selector, location или function без преобразования в строку.
- `group: none` сбрасывает нумерацию секции; одинаковая строковая группа продолжает нумерацию.
- Если используется несколько sections, fixture обязан доказать, что каждая citation попала ровно в одну bibliography.
## Намеренно НЕ обрабатывает
- Редактирование `.bib` и Hayagriva-файлов.
- Поиск научных источников.
- Ссылки на внешние URL без label.
- Автоматическое определение падежа по окружающему тексту.
## Заметки для агента
> Typst 0.15 нативно распределяет citations между несколькими bibliographies. Не воспроизводите этот алгоритм вручную. Domain хранит настройки, presentation вызывает built-in и смещает уровень заголовка библиографии по официальной схеме. Добавьте fixtures для поглавных, тематических, общей и раздельной нумерации.
@@ -0,0 +1,42 @@
# Модуль: Публичные примеры документов
**Ответственность**: хранит исполняемую документацию четырёх профилей и источник безопасной установки выбранного типа.
**Расположение**: `docs/examples/`
## Публичный интерфейс
| Каталог | Назначение |
|---------|------------|
| `documents/report/` | Полный отчёт с главами, bibliography и appendix |
| `documents/letter/` | Деловое письмо |
| `documents/commercial-offer/` | Технико-коммерческое предложение |
| `documents/contract/` | Договор |
| `formatting/` | Каталог рисунков, таблиц, формул, ссылок и списков |
| `private/settings.typ` | Безопасный полный пример private settings без PNG |
## Зависимости
| Модуль | Что использует |
|--------|----------------|
| `/.template/lib/index.typ` | Только публичный фасад |
| `use-starter.ps1` | Backup и копирование примера в корень |
| Presentation profiles | Реальный layout каждого вида документа |
## Инварианты
- Каждый `main.typ` компилируется непосредственно из своего каталога.
- Тот же каталог компилируется после копирования в корень.
- Каждый пример явно показывает `company-id`, `document-mode`, `use-private-assets`, profile metadata и media policy.
- Public example не зависит от `.template/development/`.
- Formatting guide содержит пояснения рядом с копируемыми блоками.
- Выбор типа создаёт backup до замены пользовательских файлов.
## Намеренно НЕ обрабатывает
- Обратную совместимость со старыми starters.
- Синхронизацию уже изменённого примера с пользовательским fork.
- Юридическую корректность демонстрационного договора.
## Заметки для агента
> Не создавайте отдельную showcase-копию: публичный пример должен одновременно быть тестовым fixture и источником установки. Изменение API считается завершённым только после обновления всех четырёх `main.typ` и formatting guide.
+44
View File
@@ -0,0 +1,44 @@
# Модуль: Тестирование
**Ответственность**: защищает слои библиотеки, единственный root entrypoint, public examples, PDF semantics и визуальную стабильность.
**Расположение**: `.template/development/tests/`
## Публичный интерфейс
| Символ или файл | Тип | Описание |
|-----------------|-----|----------|
| `run-tests.py` | CLI | Полный release gate |
| `manifest.json` | fixture manifest | Compile, semantic, negative и visual expectations |
| `check_architecture()` | static check | Направление Typst imports |
| `check_workspace()` | static check | Root surface, docs, VS Code, private settings и public examples |
| `snapshots/` | visual baseline | Утверждённые страницы стабильных renderer fixtures |
## Зависимости
| Компонент | Что использует |
|-----------|----------------|
| Typst 0.15.1+ | Компиляция и PDF generation |
| Poppler | Text extraction, links и PNG rendering |
| Pillow | Pixel diff snapshots |
| `docs/examples/` | Public executable documentation |
## Инварианты
- Корень не содержит `document.typ`, `draft.typ` и `clean-copy.typ`.
- `docs/` видим и содержит обязательную public navigation.
- Все четыре документа и formatting guide компилируются.
- Generic profile fixtures проверяют `final`, `draft` и `clean-copy` без root entrypoint duplication.
- Безопасный private settings example содержит всех сотрудников с отключёнными PNG.
- Unit tests проверяют public directory, private offsets и отсутствие подписи.
- Public company profiles не содержат настоящих sign/stamp.
- Snapshot изменяется только после ручного visual review.
## Намеренно НЕ обрабатывает
- Юридическую корректность договоров.
- Инженерную достоверность example values.
- Установку сторонних extensions.
## Заметки для агента
> Public example является production-facing documentation: его compile failure блокирует выпуск так же, как regression renderer. Для layout changes сначала просмотрите rendered pages, затем осознанно обновите snapshots.
@@ -0,0 +1,38 @@
# Модуль: Публичная документация
**Ответственность**: проводит автора без опыта программирования от открытия fork до проверенного PDF.
**Расположение**: `README.md`, `docs/`
## Публичный интерфейс
| Документ | Назначение |
|----------|------------|
| `README.md` | Краткий обязательный маршрут и все важные ссылки |
| `docs/README.md` | Полная пользовательская навигация |
| `docs/documents.md` | Один main, режимы, профили, главы и assets |
| `docs/formatting.md` | Таблицы, рисунки, формулы и ссылки |
| `docs/examples/` | Компилируемые исходники |
| `docs/vscode.md` | Расширения, preview и tasks |
| `docs/git.md` | Совместная работа простыми словами |
| `docs/private-assets.md` | Копирование `.private`, один переключатель, роли и offsets |
| `docs/writing-style.md` | Заготовка для редактирования технического текста |
| `docs/troubleshooting.md` | Диагностика типовых проблем |
## Инварианты
- `docs/` виден в Explorer и находится в корне.
- Все обязательные сценарии доступны из root README.
- Каждый Typst-фрагмент с нетривиальным API имеет компилируемую версию.
- Термины Git объясняются бытовыми аналогиями.
- Developer details остаются в `.template/development/`.
- Документация не обещает автоматическую установку внутренних VSIX.
## Намеренно НЕ обрабатывает
- DDD architecture и migration internals.
- Обучение программированию.
- Администрирование Git-сервера.
## Заметки для агента
> Любое новое пользовательское действие сначала появляется в root README, затем раскрывается в `docs/`. Проверяйте относительные Markdown links и компиляцию public examples автоматически.
@@ -0,0 +1,34 @@
# Модуль: Рабочая область VS Code
**Ответственность**: синхронизирует воспроизводимую среду автора и предоставляет три понятные задачи.
**Расположение**: `.vscode/`, `.template/development/tools/`
## Публичный интерфейс
| Файл или задача | Назначение |
|-----------------|------------|
| `extensions.json` | Десять согласованных recommendations |
| `settings.json` | Auto Save, spellcheck, TODO, скрытие служебных каталогов |
| `Scientia: собрать PDF` | Компиляция текущего `main.typ` |
| `Scientia: выбрать тип документа` | Backup и установка public example |
| `Scientia: собрать учебный пример` | Компиляция выбранного исходника из `docs/examples/` |
## Инварианты
- `.vscode/` включён в Git, но скрыт в Explorer.
- `docs/` никогда не скрыт.
- Typewriter navigator содержит только `main.typ`.
- Автосохранение не создаёт commit и не выполняет push.
- Tasks не хранят приватные значения в tracked settings.
- Одна build task собирает и public-, и private-режим согласно `main.typ`.
- Внутренние VSIX не входят в репозиторий.
## Намеренно НЕ обрабатывает
- Установку VS Code, Git и Typst.
- Авторизацию на Git-сервере.
- Публикацию внутренних VSIX.
## Заметки для агента
> Не добавляйте отдельную build task для каждого режима: пользователь меняет `document-mode` в `main.typ`. Новая задача должна либо быть частой, либо существенно снижать риск потери данных.
@@ -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)
@@ -0,0 +1,63 @@
param(
[Parameter(Mandatory = $true)]
[ValidateSet('report', 'letter', 'commercial-offer', 'contract')]
[string]$Name
)
$ErrorActionPreference = 'Stop'
$workspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
$starterRoot = [System.IO.Path]::GetFullPath(
(Join-Path $workspaceRoot "docs\examples\documents\$Name")
)
$mainTarget = [System.IO.Path]::GetFullPath((Join-Path $workspaceRoot 'main.typ'))
$chaptersTarget = [System.IO.Path]::GetFullPath((Join-Path $workspaceRoot 'chapters'))
$assetsTarget = [System.IO.Path]::GetFullPath((Join-Path $workspaceRoot 'assets'))
foreach ($path in @($starterRoot, $mainTarget, $chaptersTarget, $assetsTarget)) {
if (-not $path.StartsWith(
$workspaceRoot + [System.IO.Path]::DirectorySeparatorChar,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Небезопасный путь за пределами проекта: $path"
}
}
if (-not (Test-Path -LiteralPath (Join-Path $starterRoot 'main.typ'))) {
throw "Не найден пример документа: $Name"
}
# Выбор типа разрешён даже при незакоммиченных изменениях: перед заменой
# создаётся локальная резервная копия, которая не попадает в Git.
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$backupRoot = [System.IO.Path]::GetFullPath(
(Join-Path $workspaceRoot ".private\starter-backups\$timestamp")
)
if (-not $backupRoot.StartsWith(
$workspaceRoot + [System.IO.Path]::DirectorySeparatorChar,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Небезопасный путь резервной копии: $backupRoot"
}
New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null
if (Test-Path -LiteralPath $mainTarget) {
Copy-Item -LiteralPath $mainTarget -Destination (Join-Path $backupRoot 'main.typ')
}
if (Test-Path -LiteralPath $chaptersTarget) {
Copy-Item -LiteralPath $chaptersTarget -Destination (Join-Path $backupRoot 'chapters') -Recurse
Remove-Item -LiteralPath $chaptersTarget -Recurse -Force
}
Copy-Item -LiteralPath (Join-Path $starterRoot 'main.typ') -Destination $mainTarget -Force
Copy-Item -LiteralPath (Join-Path $starterRoot 'chapters') -Destination $chaptersTarget -Recurse
$starterAssets = Join-Path $starterRoot 'assets'
if (Test-Path -LiteralPath $starterAssets) {
New-Item -ItemType Directory -Path $assetsTarget -Force | Out-Null
Copy-Item -Path (Join-Path $starterAssets '*') -Destination $assetsTarget -Recurse -Force
}
Write-Host "Установлен тип документа '$Name'."
Write-Host "Предыдущие main.typ и chapters/ сохранены в $backupRoot"
Write-Host 'Откройте main.typ и последовательно проверьте параметры.'
+7
View File
@@ -0,0 +1,7 @@
# VS Code для сопровождающего шаблона
Корневые `.vscode/extensions.json`, `.vscode/settings.json` и `.vscode/tasks.json` намеренно синхронизируются через Git: это часть пользовательского опыта шаблона.
`local.typst-typewriter` и `zotst.zotst` обнаружены как локально установленные расширения. Рекомендация в `extensions.json` сообщает VS Code их идентификаторы, но не распространяет сам пакет. Для нового компьютера соответствующий `.vsix` нужно получить из внутреннего хранилища и выполнить команду **Extensions: Install from VSIX**.
Если принято решение хранить внутренние `.vsix` рядом с шаблоном, размещайте их в `packages/` этого каталога и добавляйте только после проверки лицензии и отсутствия секретов.
@@ -0,0 +1,8 @@
# Внутренние VSIX-пакеты
Здесь могут храниться проверенные установочные пакеты локальных расширений:
- `local.typst-typewriter`;
- `zotst.zotst`.
Перед добавлением `.vsix` проверьте лицензию, состав архива и отсутствие секретов. Само наличие extension ID в `.vscode/extensions.json` не распространяет локальное расширение.