Compare commits
5 Commits
f7136bd8bd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fc1f9e096 | |||
| 8223e0ac14 | |||
| daa84f8bce | |||
| 7fc112e2ce | |||
| 5fb5010571 |
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: NewGUI technology stack
|
||||
overview: Отдельное Electron-приложение в desktop/. Существующие пакеты, GUI и сборка exe не меняются. Сейчас только каркас, на который потом сядут три редактора и Python.
|
||||
todos:
|
||||
- id: scaffold
|
||||
content: Папка desktop/ — Electron, Vite, React, TypeScript, Tailwind, токены из NewGUI/DESIGN.md, шрифты и иконки локально
|
||||
status: completed
|
||||
- id: shell
|
||||
content: Каркас окна по макету — шапка, сайдбар, центр, статус-бар. Данные статичные, без файлов проекта и без md2gost
|
||||
status: completed
|
||||
- id: editors
|
||||
content: Общий буфер строки и три сменных панели — CodeMirror (код и Obsidian) и TipTap (Notion), без диалекта md2gost
|
||||
status: completed
|
||||
- id: bridge
|
||||
content: Пустой контракт preload/IPC под будущий Python-сайдкар. Реализации convert и check нет
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# База NewGUI
|
||||
|
||||
Новый код живёт только в [desktop/](desktop/). [md2gost/](md2gost/), [md2latex/](md2latex/), [md2fodt/](md2fodt/), [word2md/](word2md/), [pyproject.toml](pyproject.toml), тесты и `build-exe*.bat` не меняются. Tkinter-окно остаётся текущим GUI. Макет [NewGUI/code.html](NewGUI/code.html) и токены [NewGUI/DESIGN.md](NewGUI/DESIGN.md) — только образец вёрстки.
|
||||
|
||||
Сборка DOCX, чекер, Word COM, Kroki и диалект (`# *ВВЕДЕНИЕ`, `%id`, `^` / `>`) в эту базу не входят. Их подключат отдельным шагом, когда каркас уже стоит.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph desktopApp [desktop]
|
||||
shell[Shell]
|
||||
session[DocumentSession]
|
||||
codePane[CodeMirror]
|
||||
obsidianPane[CodeMirror_live]
|
||||
notionPane[TipTap]
|
||||
bridge[HostApi_stub]
|
||||
shell --> session
|
||||
session --> codePane
|
||||
session --> obsidianPane
|
||||
session --> notionPane
|
||||
shell --> bridge
|
||||
end
|
||||
md2gost[md2gost_unchanged]
|
||||
bridge -.->|"позже"| md2gost
|
||||
```
|
||||
|
||||
## Что появляется
|
||||
|
||||
- **Стек:** Electron, Vite, React, TypeScript, Tailwind. Свой `package.json` внутри `desktop/`. Установщик NSIS на этом шаге не собирается: приложение запускается через `npm run dev`.
|
||||
- **Тема:** цвета, шрифты, радиусы и отступы из `DESIGN.md`. Space Grotesk, Inter, JetBrains Mono и Material Symbols лежат в `desktop/`, без CDN.
|
||||
- **Оболочка:** шапка, сайдбар, центральная область и статус-бар по макету. Подписи и дерево — заглушки из макета, не чтение диска и не вызов `convert()`.
|
||||
- **Три ввода одной строки.** `DocumentSession` держит текст в памяти.
|
||||
- Код — CodeMirror 6, сырой текст, номера строк.
|
||||
- Obsidian — тот же CodeMirror, режим live preview. Переключение с кода буфер не пересоздаёт.
|
||||
- Notion — TipTap с обычным Markdown на вход и выход. Узлов ГОСТ нет: спецразделы, слияния ячеек и `%id` появятся позже.
|
||||
- **Мост:** в preload один объект `HostApi` с методами-заглушками (`open`, `save`, `check`, `build`). Они возвращают «не подключено». Python-процесс не стартует.
|
||||
|
||||
## Чего нет в этом шаге
|
||||
|
||||
- Правок пакетов Python и точки входа `python -m md2gost`.
|
||||
- Сайдкара, PyInstaller и electron-builder.
|
||||
- Окна спецификации диалекта, предпросмотра A4 и вставки сниппетов.
|
||||
- Сериализации диалекта md2gost в TipTap.
|
||||
@@ -1,30 +1,25 @@
|
||||
# ai/ — промпты и документация для нейросетей
|
||||
# Промпты и документация для нейросетей
|
||||
|
||||
Материалы md2gost, переписанные под работу с языковыми моделями.
|
||||
|
||||
**Главное правило: файл из `ai/prompts/` — это целиком промпт.** В нём нет ни заголовка «скопируйте текст ниже», ни разделителей, ни инструкций для человека. Открыл файл, выделил всё, вставил в модель — работает. Инструкции для человека живут только в этом README.
|
||||
|
||||
Файлы из `ai/context/` — справочники: их дают модели как дополнительный контекст, когда нужна точность в узком месте. Файлы из `ai/examples/` — эталонные документы, проходящие проверку `--check` без замечаний; их дают как образец.
|
||||
|
||||
---
|
||||
**Данная документация в первую очередь предназначена для человека.**
|
||||
|
||||
## 1. Что где лежит
|
||||
|
||||
### `ai/prompts/` — готовые к вставке промпты
|
||||
### `AIV2/prompts/` — готовые к вставке промпты
|
||||
|
||||
| Файл | Когда брать | Размер |
|
||||
|------|-------------|--------|
|
||||
| [`core-md2gost.md`](prompts/core-md2gost.md) | нужен просто markdown в диалекте md2gost, без привязки к типу учебной работы | 12 тыс. знаков |
|
||||
| [`report-mirea.md`](prompts/report-mirea.md) | отчёт по практике, курсовая, ВКР (`practice`, `coursework`, `vkr`) | 12 тыс. знаков |
|
||||
| [`report-apid.md`](prompts/report-apid.md) | курсовая «Архитектура приложений и данных» (`APID_coursework`) | 10 тыс. знаков |
|
||||
| [`report-pis.md`](prompts/report-pis.md) | итоговый отчёт по практическим работам (`PIS_custom`) | 7 тыс. знаков |
|
||||
| [`voice-human.md`](prompts/voice-human.md) | добавка к любому из промптов выше: живой текст про предмет, а не отчёт модели о сдаче задания | 5 тыс. знаков |
|
||||
| [`report-pis.md`](prompts/report-pis.md) | итоговый отчёт по практическим работам (`PIS_custom`), если бланк требует «Практическая работа №N» | 7 тыс. знаков |
|
||||
| [`report-paco.md`](prompts/report-paco.md) | нарастающий отчёт ПАЦО и аналоги (`practice`): пункты `# 1 …` без слов «Практическая работа» | 9 тыс. знаков |
|
||||
| [`voice-human.md`](prompts/voice-human.md) | добавка: текст про предмет, а не отчёт модели; для практик не отменяет собственно-научный стиль | 5 тыс. знаков |
|
||||
| [`review-fix.md`](prompts/review-fix.md) | есть готовый `.md`, надо починить его под ТЗ (в том числе по выводу `--check`) | 8 тыс. знаков |
|
||||
| [`from-word.md`](prompts/from-word.md) | есть результат импорта `word2md`, надо довести до диалекта | 6 тыс. знаков |
|
||||
|
||||
Промпты самодостаточны: правила диалекта в них повторяются намеренно, чтобы каждый работал в одиночку.
|
||||
|
||||
### `ai/context/` — справочники для догрузки
|
||||
### `AIV2/context/` — справочники для догрузки
|
||||
|
||||
| Файл | О чём | Размер |
|
||||
|------|-------|--------|
|
||||
@@ -33,8 +28,10 @@
|
||||
| [`archimate.md`](context/archimate.md) | ArchiMate 3.2: макросы элементов и связей, направления, viewpoints, шаблоны | 22 тыс. знаков |
|
||||
| [`checker.md`](context/checker.md) | каталог замечаний `--check` с текстами сообщений и способами починки | 10 тыс. знаков |
|
||||
| [`cli.md`](context/cli.md) | все ключи запуска, профили типов, конфигурационные файлы, оформление | 12 тыс. знаков |
|
||||
| [`academic-style.md`](context/academic-style.md) | собственно-научный стиль, заголовки пунктов, аббревиатуры, ссылки `[1, 2]`, границы пунктов | 8 тыс. знаков |
|
||||
| [`org-hierarchy.md`](context/org-hierarchy.md) | читаемость рисунка, развязка коннектора, полнота отдела (руководитель + специалисты) | 6 тыс. знаков |
|
||||
|
||||
### `ai/examples/` — эталоны
|
||||
### `AIV2/examples/` — эталоны
|
||||
|
||||
| Файл | Тип | Что демонстрирует |
|
||||
|------|-----|-------------------|
|
||||
@@ -43,11 +40,11 @@
|
||||
|
||||
Оба файла проверены: `--check-only` не даёт ни одного замечания, сборка DOCX проходит на локальном рендере без интернета.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2. Рецепты
|
||||
|
||||
Что вставлять в модель под конкретную задачу.
|
||||
Какой файл вставлять в диалог под конкретную задачу.
|
||||
|
||||
| Задача | Что даёшь модели |
|
||||
|--------|------------------|
|
||||
@@ -55,6 +52,7 @@
|
||||
| То же, но нужен упор на схемы | добавь `context/diagrams.md` перед входными данными |
|
||||
| Курсовая АПИД | `prompts/report-apid.md` + `prompts/voice-human.md` + при необходимости `context/archimate.md` |
|
||||
| Отчёт по практическим работам ПИС | `prompts/report-pis.md` + входные данные |
|
||||
| Нарастающий отчёт ПАЦО (пункты задания, не «Практическая работа») | `prompts/report-paco.md` + `context/academic-style.md` + `context/org-hierarchy.md` + входные данные |
|
||||
| Дописать раздел в существующий отчёт | `prompts/core-md2gost.md` + `prompts/voice-human.md` + текущий `.md` целиком + что дописать |
|
||||
| Починить файл по замечаниям проверки | `prompts/review-fix.md` + вывод `--check` + `.md` целиком |
|
||||
| Довести импорт из Word | `prompts/from-word.md` + результат `word2md` |
|
||||
@@ -72,9 +70,7 @@
|
||||
Следующим сообщением после промпта дай модели заполненный блок:
|
||||
|
||||
```text
|
||||
Тип работы: coursework
|
||||
Тема: Разработка модуля учёта заявок на техническое обслуживание
|
||||
Год сдачи: 2026
|
||||
Дисциплина: Проектирование информационных систем
|
||||
|
||||
Объект: процесс приёма и исполнения заявок на обслуживание оборудования
|
||||
@@ -136,12 +132,13 @@ python -m md2gost report.docx --check-pages
|
||||
|
||||
- **список литературы** — модели склонны придумывать издания, ISBN и число страниц. Промпты запрещают это прямо, но проверять всё равно нужно;
|
||||
- **числа и названия** — если ты их не давал, модель их сочинила;
|
||||
- **соответствие теме** — структура может быть безупречной при пустом содержании.
|
||||
- **соответствие теме** — структура может быть безупречной при пустом содержании;
|
||||
- **стиль и схемы** — публицистика, `[1], [2]`, таблица реквизитов, нечитаемая оргсхема и материал не из своего пункта `--check` не ловит; сверяй `academic-style.md` и `org-hierarchy.md`. Для следующих практик этот разбор делается до сдачи.
|
||||
|
||||
---
|
||||
|
||||
## 7. Отношение к остальному репозиторию
|
||||
|
||||
Каталоги `docs/` и `prompts/` в корне репозитория остаются как есть: на них ссылается встроенная справка приложения (**Справка → Документация**, **Справка → Промпт для ИИ**). Каталог `ai/` — самостоятельный набор, переписанный под подачу в модели, и ничего в приложении не ломает.
|
||||
Каталоги `docs/` и `prompts/` в корне репозитория остаются как есть: на них ссылается встроенная справка приложения (**Справка → Документация**, **Справка → Промпт для ИИ**).
|
||||
|
||||
Источник истины по возможностям конвертера — код в `md2gost/`; справочники в `ai/context/` собраны по нему, а не по старой документации.
|
||||
Источник истины по возможностям конвертера — код в `md2gost/`; справочники в `AI-Docs_WriteHuman/context/` собраны по нему, а не по старой документации.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Собственно-научный стиль отчёта по практикам
|
||||
|
||||
Справочник для модели: как писать текст отчёта, чтобы он проходил проверку преподавателя по содержанию, а не только `--check` по разметке. Извлечено из типовых замечаний к отчётам ПАЦО / проектным практикам.
|
||||
|
||||
---
|
||||
|
||||
## 1. Подстиль
|
||||
|
||||
Текст отчёта — **собственно-научный подстиль** русского научного стиля (как в отчёте о НИР, ГОСТ 7.32). Это не публицистика, не консультантский слайд и не набор тезисов.
|
||||
|
||||
Допустимо: безличные конструкции, термины, факты, ссылки, причинно-следственные формулировки («обусловлено», «определяется», «характеризуется»).
|
||||
|
||||
Недопустимо:
|
||||
|
||||
| Запрещённый приём | Пример | Как заменить |
|
||||
|-------------------|--------|--------------|
|
||||
| Метафора и давление | «ручная проверка давит на маржу» | «трудоёмкость ручной проверки комплектов увеличивает операционные издержки» |
|
||||
| Разговорный вектор | «социальные факторы тянут к…» | «в социальной среде наблюдается спрос на…» |
|
||||
| Лозунг с “зато” | «платформа усложняется, зато сведения можно сверять автоматически» | «развитие программных интерфейсов реестров создаёт возможность автоматической сверки сведений» |
|
||||
| Живая разговорность | «экосистема растёт», «общество тянет массу группы», «нет смысла в отделе» | нейтральная констатация факта |
|
||||
| Тезисная рубка | «Экономические факторы — спрос. Социальные — Госключ. Технологические — облако.» | связные абзацы, каждый фактор раскрыт предложением |
|
||||
| Мета про сдачу | «сформирована характеристика объекта» | содержание объекта, а не факт выполнения пункта |
|
||||
|
||||
Проверка абзаца: его можно поставить в отчёт о НИР без ощущения статьи или презентации. Если нет — переписать.
|
||||
|
||||
«Живой голос» из `voice-human.md` означает конкретику и отсутствие мета-чеклиста модели. Он **не** означает публицистические глаголы и антитезы.
|
||||
|
||||
---
|
||||
|
||||
## 2. Заголовки пунктов практики
|
||||
|
||||
Если задание называет пункт «Характеристика объекта исследования» — заголовок раздела:
|
||||
|
||||
```markdown
|
||||
# 1 Характеристика объекта исследования
|
||||
```
|
||||
|
||||
**Нельзя** писать `# Практическая работа 1 …` и `# Практическая работа №1. …`, если руководитель прямо потребовал убрать слова «Практическая работа» из названия пункта.
|
||||
|
||||
Профиль `PIS_custom` в `--check` как раз требует «Практическая работа №N. …». Для отчётов ПАЦО с нумерованными пунктами задания собирай документ как `--type practice` (или `coursework`), а не `PIS_custom`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Подпункты
|
||||
|
||||
Не дроби один пункт задания на искусственные `##` («Юридический статус», «Специфика», «Организационная модель» и т. п.), если преподаватель запретил лишние подразделы.
|
||||
|
||||
Один пункт задания = один H1 (или один нумерованный раздел). Смена темы внутри пункта — новый абзац, а не новый заголовок. Рисунок и таблица сами членят текст.
|
||||
|
||||
Подразделы `##` оставляй только если их явно требует бланк задания или профиль `--check` (цель / ход / выводы в `PIS_custom`; фиксированные `## 2.1` в АПИД).
|
||||
|
||||
---
|
||||
|
||||
## 4. Полное наименование, затем сокращение
|
||||
|
||||
При **первом** появлении в тексте:
|
||||
|
||||
1. полное официальное наименование;
|
||||
2. затем сокращение или аббревиатура в скобках, при необходимости с «далее —».
|
||||
|
||||
```text
|
||||
Акционерное общество «Производственная фирма «СКБ Контур» (далее — АО «ПФ «СКБ Контур») является акционерным обществом частной формы собственности…
|
||||
электронный документооборот (ЭДО)
|
||||
удостоверяющий центр (УЦ)
|
||||
машиночитаемая доверенность (МЧД)
|
||||
```
|
||||
|
||||
Дальше по тексту — только принятое сокращение. Не начинай документ с аббревиатуры, которую ещё не ввели. Не вводи одно и то же сокращение дважды.
|
||||
|
||||
---
|
||||
|
||||
## 5. Таблица реквизитов
|
||||
|
||||
Реквизиты юридического лица (наименование, ИНН, ОГРН, адрес, директор, капитал) **не оформлять таблицей «Реквизит / Значение»**. Значимые сведения из такой таблицы переносятся в связный абзац.
|
||||
|
||||
Таблицу оставляй там, где сравниваются однородные ряды: коды ОКВЭД, динамика показателей по годам, сопоставление конкурентов. Таблица из одной строки данных не нужна.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ссылки на источники
|
||||
|
||||
Несколько источников на одно утверждение — **одной скобкой**:
|
||||
|
||||
| Нельзя | Надо |
|
||||
|--------|------|
|
||||
| `[1], [2]` | `[1, 2]` |
|
||||
| `[2], [5]` | `[2, 5]` |
|
||||
| `[1], [2], [5]` | `[1, 2, 5]` |
|
||||
| `[6], [7]` | `[6, 7]` |
|
||||
|
||||
Одиночная ссылка `[1]` допустима. Форма с страницами: `[2, c. 21-25]`. Во введении и заключении ссылок нет.
|
||||
|
||||
---
|
||||
|
||||
## 7. Границы пункта задания
|
||||
|
||||
Факт относится к тому пункту, которому он принадлежит.
|
||||
|
||||
- Характеристика **объекта**: статус, виды деятельности, оргмодель, карта верхнего уровня, макро- и микросреда, масштаб.
|
||||
- Нельзя в характеристике объекта разворачивать узкое место процесса, параметры оптимизации, «рост заявок упирается в ручную проверку» — это пункт про предмет / процесс (практика 2 и далее).
|
||||
- В пункте про процесс не повторять заново всю справку об объекте.
|
||||
|
||||
---
|
||||
|
||||
## 8. Критический разбор до сдачи
|
||||
|
||||
На текущем принятом этапе достаточно формальной комплектности. **В последующих практиках** студент до загрузки файла сам разбирает представляемый фрагмент проектного решения:
|
||||
|
||||
1. Все ли обязательные артефакты пункта на месте и читаемы.
|
||||
2. Согласованы ли объект, оргмодель, карта и текст процесса.
|
||||
3. Нет ли публицистики, лишних подразделов, таблиц-дублей текста.
|
||||
4. Выдержана ли иерархия на оргсхеме (см. `org-hierarchy.md`).
|
||||
5. Не попал ли материал не в свой пункт.
|
||||
|
||||
Модель, которая готовит следующую практику, проводит этот разбор молча и правит текст **до** выдачи файла. Не вставляй в отчёт фразу «проведён критический анализ».
|
||||
@@ -39,7 +39,8 @@
|
||||
| ` ```archimate `, ` ```uml-archimate ` | макросы ArchiMate 3.2, без обёртки | PlantUML + stdlib ArchiMate |
|
||||
| ` ```mermaid `, ` ```mmd ` | синтаксис Mermaid | локальный Mermaid или Kroki |
|
||||
| ` ```idef0 `, ` ```uml-idef0 ` | DSL IDEF0 | локальный рендер (Pillow) |
|
||||
| ` ```dfd `, ` ```uml-dfd `, ` ```data-flow-diagram `, ` ```yourdon ` | DSL потоков данных | Graphviz или Kroki |
|
||||
| ` ```dfd `, ` ```uml-dfd `, ` ```data-flow-diagram `, ` ```yourdon ` | DSL потоков данных (Йордон) | Graphviz или Kroki |
|
||||
| ` ```dfd-gs `, ` ```gane-sarson ` | DSL потоков данных (Гейн–Сарсон) | локальный рендер (Pillow) |
|
||||
|
||||
Правило про обёртку: в ` ```uml ` пишешь `@startuml` … `@enduml` **обязательно**; в схемах (`c4`, `usecase`, `archimate` и любых из `md2gost.schemes.json`) — **никогда**, схема подставит обёртку и `!include` сама.
|
||||
|
||||
@@ -527,6 +528,35 @@ Ship --> Client уведомление
|
||||
|
||||
Правила нотации: на контекстной диаграмме один процесс; хранилища не соединяются напрямую с внешними сущностями; каждый процесс имеет и вход, и выход.
|
||||
|
||||
### Гейн–Сарсон
|
||||
|
||||
Оградка ` ```dfd-gs ` (то же: ` ```gane-sarson `, ` ```uml-dfd-gs `). Те же `entity` / `process` / `store` и `A --> B подпись`, но фигуры другие: сущность — прямоугольник с тенью и номером, процесс — скруглённый прямоугольник с номером внизу справа, хранилище — прямоугольник с полосой и кодом `D1`. Рендер локальный, Graphviz не нужен.
|
||||
|
||||
Номер процесса — сам id, если это `1` или `3.1`, иначе порядок объявления. Код хранилища — id вида `D2`, иначе `D1`, `D2`, … по порядку. У сущности номер в углу — порядок объявления.
|
||||
|
||||
Шапка листа, как у Ramus: `title`, `node`, `number`, `author`, `project`, `date`, `rev`, `status`, `used_at`. `form kit` (по умолчанию, со штампом) или `form plain`.
|
||||
|
||||
Раскладка сама: сущности слева, процессы в центре, хранилища справа. Стрелка между соседними колонками идёт по зазору. Координат в тексте нет.
|
||||
|
||||
~~~markdown
|
||||
%dfd_gs Приёмка работы
|
||||
|
||||
```dfd-gs
|
||||
title Зафиксировать решение приёмки
|
||||
node A44
|
||||
number 1
|
||||
author Иванов И. И.
|
||||
project ИС ArtFreelance
|
||||
|
||||
entity Artist Художник
|
||||
process 1 Принять файлы результата
|
||||
store D2 Файлы работ
|
||||
|
||||
Artist --> 1 файлы работы
|
||||
1 --> D2 превью и хеш
|
||||
```
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
## 8. Что нужно для отрисовки
|
||||
@@ -536,7 +566,8 @@ Ship --> Client уведомление
|
||||
| PlantUML, C4, ArchiMate, Use Case | Java и `plantuml.jar` (вшит в exe-сборку) | локальный Kroki, затем `kroki.io` при `--diagram-fallback remote` |
|
||||
| Mermaid | системный Chrome или Edge; либо скачанный Chromium; либо QuickJS | Kroki |
|
||||
| IDEF0 | ничего, локальный рендер | не требуется |
|
||||
| DFD | Graphviz (вшитый, из `PATH` или кнопка «Скачать Graphviz») | Kroki |
|
||||
| DFD (Йордон, ` ```dfd `) | Graphviz (вшитый, из `PATH` или кнопка «Скачать Graphviz») | Kroki |
|
||||
| DFD Гейна–Сарсона (` ```dfd-gs `) | ничего, локальный рендер | не требуется |
|
||||
|
||||
Ключи: `--plantuml-jar`, `--kroki-url`, `--diagram-fallback local|remote|off`, `--diagram-format png|svg`, `--diagram-scale N` (по умолчанию 2 — качество растра).
|
||||
|
||||
@@ -544,7 +575,27 @@ Ship --> Client уведомление
|
||||
|
||||
---
|
||||
|
||||
## 9. Свои схемы
|
||||
## 9. Иерархическая оргсхема и читаемость
|
||||
|
||||
Рисунок, который нельзя прочитать при масштабе 100 % в Word, при проверке равен отсутствию рисунка. Широкие схемы помечай `+landscape`. Кегль подписей после вставки на лист — не меньше 10–11 пт. Если встроенный PlantUML или Mermaid даёт мелкую схему, сдавай PNG высокого разрешения.
|
||||
|
||||
На оргмодели из одного узла выходит **одна** связь, затем развязка на подчинённых (горизонтальная шина). Пачка независимых линий `ceo -- a`, `ceo -- b`, `ceo -- c` — ошибка. Стрелки не используются.
|
||||
|
||||
У каждого показанного отдела есть руководитель и специалисты операционного уровня. Нельзя оставлять бокс направления с одним специалистом или без людей. Должности обезличены.
|
||||
|
||||
Каркас развязки:
|
||||
|
||||
```text
|
||||
[Родитель]
|
||||
|
|
||||
---------+---------
|
||||
| | |
|
||||
[Доч.1] [Доч.2] [Доч.3]
|
||||
```
|
||||
|
||||
Карта процессов верхнего уровня — группы без стрелок последовательности.
|
||||
|
||||
## 10. Свои схемы
|
||||
|
||||
Файл `md2gost.schemes.json` (рядом с приложением, рядом с `.md` или путь в `--schemes`) задаёт новые оградки:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Иерархические схемы в отчёте
|
||||
|
||||
Правила организационной модели и карты процессов верхнего уровня. Нарушение приравнивается преподавателем к отсутствию рисунка или к неверной модели.
|
||||
|
||||
---
|
||||
|
||||
## 1. Читаемость = наличие рисунка
|
||||
|
||||
Рисунок, который нельзя разобрать при масштабе 100 % в Word (и который не читается даже при увеличении), **считается отсутствующим**.
|
||||
|
||||
Обязательно:
|
||||
|
||||
- крупный кегль подписей: на листе после вставки в DOCX не меньше 10–11 пт;
|
||||
- флаг `+landscape` для широких схем;
|
||||
- не более одной мысли на бокс, без «простыни» из 8-пунктового текста;
|
||||
- чёрные линии на белом, без декоративной заливки, мешающей печати;
|
||||
- если встроенный PlantUML/Mermaid после сборки даёт мелкую кашу — вынести схему в отдельный PNG высокого разрешения (`figsize` 14–18 дюймов по ширине, DPI ≥ 200) и вставить как ``.
|
||||
|
||||
Нельзя сдавать схему, которую «можно рассмотреть, если открыть исходник». Проверяют PDF/DOCX.
|
||||
|
||||
---
|
||||
|
||||
## 2. Принцип развязки коннектора
|
||||
|
||||
На иерархической оргсхеме **из одного узла выходит одна связь**, которая затем разветвляется на подчинённых (горизонтальная шина / Т-образный коннектор).
|
||||
|
||||
Неправильно: от «Генеральный директор» independently идут 6–8 отдельных линий к каждому департаменту.
|
||||
|
||||
Правильно:
|
||||
|
||||
```text
|
||||
[Родитель]
|
||||
|
|
||||
---------+--------- ← одна развязка
|
||||
| | |
|
||||
[Доч.1] [Доч.2] [Доч.3]
|
||||
```
|
||||
|
||||
В matplotlib / ручной отрисовке: вертикаль вниз от родителя → горизонталь по центрам детей → вертикали в каждый ребёнок.
|
||||
|
||||
В PlantUML не писать пачку `ceo -- a`, `ceo -- b`, `ceo -- c` без общей шины. Либо скрытый стыковочный узел, либо заранее собранный PNG с развязкой.
|
||||
|
||||
Стрелки на оргмодели не нужны: связь иерархическая, не поток.
|
||||
|
||||
---
|
||||
|
||||
## 3. Полнота подразделения
|
||||
|
||||
Если на схеме есть **отдел / департамент / направление**, у него изображаются:
|
||||
|
||||
1. руководитель (начальник отдела, руководитель направления, директор по функции);
|
||||
2. специалисты операционного уровня.
|
||||
|
||||
Нельзя оставлять бокс «Отдел выпуска» с одним «специалистом» и без начальника. Нельзя оставлять бокс направления без людей: пустое имя подразделения приравнивается к неполной структуре.
|
||||
|
||||
Типовые наименования брать из справочника должностей, не выдумывать креатив. Фамилии не писать — только должности.
|
||||
|
||||
Детализация:
|
||||
|
||||
- подразделение, где сидят исполнители выбранного процесса, — до начальников участков и операционных специалистов;
|
||||
- прочие направления — типовой минимум: руководитель + специалист;
|
||||
- элементы, которые не понадобятся процессу, на схему не выносить, но то, что вынесено, должно быть полным.
|
||||
|
||||
---
|
||||
|
||||
## 4. Карта процессов верхнего уровня
|
||||
|
||||
- Группы процессов (основные, обеспечивающие, развития; управление — только если этого требует задание).
|
||||
- Внутри группы — наименования направлений, не клики и не технология выполнения.
|
||||
- Стрелок последовательности на карте верхнего уровня нет.
|
||||
- Предмет последующего исследования можно отметить сноской на элементе, без разбора узкого места: узкое место относится к пункту про процесс.
|
||||
|
||||
---
|
||||
|
||||
## 5. Каркас иерархии (PNG)
|
||||
|
||||
```text
|
||||
Генеральный директор
|
||||
|
|
||||
---------------------+---------------------
|
||||
| | | | |
|
||||
Рук. направл. Рук. сети Рук. направл. … Рук. службы
|
||||
(процессное) СЦ (типовое)
|
||||
|
|
||||
-----+-----
|
||||
| | |
|
||||
Нач. Нач. Нач.
|
||||
участка
|
||||
|
|
||||
Специалист
|
||||
```
|
||||
|
||||
Подписи — должность и принадлежность («специалист центра регистрации»), не «владелец процесса» и не комментарий для преподавателя.
|
||||
@@ -309,7 +309,7 @@ Fenced-блок с языком диаграммы превращается в
|
||||
| Форма | Пример |
|
||||
|-------|--------|
|
||||
| одиночная | `[1]`, `[1.5]` |
|
||||
| перечисление | `[2, 3]`, `[2,3]` |
|
||||
| перечисление | `[2, 3]`, `[2,3]` — **нельзя** писать `[2], [3]` |
|
||||
| с указанием страниц | `[2, c. 21-25]`, `[2.18, с. 21–25]` — буква `c` латинская или русская |
|
||||
|
||||
В DOCX номер становится внутренней гиперссылкой на запись списка.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Документация md2gost
|
||||
|
||||
Конвертер Markdown → DOCX для учебных работ (ТЗ МИРЭА / ГОСТ 7.32, ПИС, АПИД).
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Быстрый старт](quickstart.md) — установка, первое окно, exe
|
||||
2. [Синтаксис Markdown](markdown.md) — чем диалект отличается от обычного MD
|
||||
3. [Схемы и диаграммы](schemes.md) — UML, Mermaid, IDEF0, DFD (вшитый Graphviz / Kroki), настраиваемые схемы
|
||||
4. [ArchiMate 3.2](archimate.md) — генерация схем (макросы PlantUML, связи, viewpoints)
|
||||
5. [Типы документов](types.md) — coursework, practice, vkr, PIS_custom, APID_coursework
|
||||
6. [CLI и GUI](cli-gui.md) — флаги и соответствие в интерфейсе
|
||||
7. [Автотитульник](title.md) — `info_conv.yaml`, блок `title`, ФИО/группа
|
||||
8. [Кастомные стили (JSON)](styles.md) — оверлей полей и стилей абзацев
|
||||
9. [Промпты для ИИ](prompts.md) — генерация `.md`, расширение промпта схемами
|
||||
10. [Импорт Word → Markdown](word2md.md) — DOCX в диалект md2gost (word2md)
|
||||
|
||||
Исходный код конвертера: [`md2gost/`](../md2gost/). Примеры: [`examples/`](../examples/).
|
||||
|
||||
Полная карта репозитория (архитектура, все функции CLI/GUI и модули кода): [`PROJECT.md`](../PROJECT.md).
|
||||
|
||||
В GUI: **Справка → Документация** — тот же набор страниц (вшит в exe).
|
||||
@@ -0,0 +1,186 @@
|
||||
# Незакрытые задачи после аудита
|
||||
|
||||
Состояние на 10 сентября 2026. Всё перечисленное — то, что осталось после прохода по плану оптимизации. Что уже сделано, см. [audit-report.md](audit-report.md).
|
||||
|
||||
Тесты сейчас: **320 passed, 1 skipped, 174 subtests**, полный прогон 11.85 с. Ничего не закоммичено — всё лежит в staging и рабочем дереве.
|
||||
|
||||
---
|
||||
|
||||
## P0 — сломает сборку, если не сделать
|
||||
|
||||
### `mdexport/` не добавлен в git
|
||||
|
||||
Пакет `mdexport/` (общий обходчик AST для `md2latex` и `md2fodt`) создан, но остался **untracked**, при этом на него уже завязаны:
|
||||
|
||||
- `pyproject.toml` — перечислен в `[tool.hatch.build.targets.wheel].packages`
|
||||
- `md2latex/emitter.py`, `md2latex/escape.py`
|
||||
- `md2fodt/emitter.py`, `md2fodt/escape.py`
|
||||
|
||||
На свежем клоне оба конвертера упадут с `ModuleNotFoundError`, сборка колеса тоже. Локально работает только потому, что файлы лежат на диске.
|
||||
|
||||
```powershell
|
||||
git add mdexport/blocks.py mdexport/inline.py mdexport/labels.py mdexport/__init__.py
|
||||
git status --short mdexport
|
||||
```
|
||||
|
||||
Скомпилированные `.pyc` внутри уже покрыты правилом `__pycache__/`, добавлять их не нужно.
|
||||
|
||||
---
|
||||
|
||||
## P1 — найденный баг, теряет данные
|
||||
|
||||
### `word2md` выбрасывает пункты списка при смене его типа
|
||||
|
||||
Найден при разборе `_handle_paragraph`. Баг **существовал и до рефакторинга**, его сохранили намеренно, чтобы правка осталась поведенчески нейтральной.
|
||||
|
||||
`word2md/walker.py`, строки 406-429. Оба обработчика списков заканчиваются на `return []`, выбрасывая накопленный `out` — притом что `_flush_code(out)` и `_flush_list(out)` внутри уже успели в него дописать:
|
||||
|
||||
```python
|
||||
def _as_numbered_list_item(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
||||
if not paragraph_has_numpr(para.paragraph):
|
||||
return None
|
||||
self._flush_code(out) # может дописать в out
|
||||
...
|
||||
self._append_list_item(ordered, body, out) # внутри вызывает _flush_list(out)
|
||||
return [] # <- out выброшен
|
||||
```
|
||||
|
||||
`_append_list_item` (строки 431-437) при смене типа списка вызывает `_flush_list(out)`, который сбрасывает накопленные пункты в `out`. После чего `return []` их теряет. Соседний `_as_plain_paragraph` (строка 439) в такой же ситуации корректно делает `return out`.
|
||||
|
||||
Воспроизведение: два упорядоченных пункта с `numPr`, за ними пункт-буллет. Оба упорядоченных пропадают из результата. Тот же механизм теряет и забуференный код, если он был.
|
||||
|
||||
Порядок работ: сначала красный тест на исчезающие пункты, потом замена `return []` на `return out` в обоих обработчиках, потом проверка, что побайтовая сверка вывода на остальных документах не поехала (она поедет только там, где баг срабатывал).
|
||||
|
||||
---
|
||||
|
||||
## P1 — решено, но не выполнено
|
||||
|
||||
### Калибровка Courier New 10 пт
|
||||
|
||||
Ты выбрал вариант «10 пт канон по табл. 4.1, добавить калибровку и перевести тест-фикстуру». **Не сделано** — я не успел запустить задачу до конца сессии.
|
||||
|
||||
Что именно нужно, в `md2gost/renderable/paragraph_sizer.py`, метод `Font.get_line_height` (строки 78-83):
|
||||
|
||||
```python
|
||||
# TODO: make it work for all fonts
|
||||
if "Times" in str(self._face.family_name) and self._freetypefont.size == 14:
|
||||
return Pt(16.05)
|
||||
if "Courier" in str(self._face.family_name) and self._freetypefont.size == 12:
|
||||
return Pt(13.61)
|
||||
```
|
||||
|
||||
Сейчас калиброванная ветка есть только для Courier 12 пт. Замеры, снятые в ходе аудита:
|
||||
|
||||
| Размер | Возвращает сейчас | Фактическая высота |
|
||||
|---|---|---|
|
||||
| Courier 10 пт | 11.000 | ≈ 11.34 |
|
||||
| Courier 11 пт | 12.000 | — |
|
||||
| Courier 12 пт | 13.610 (калибровка) | 13.61 |
|
||||
| Courier 14 пт | 16.000 | — |
|
||||
|
||||
Целые числа выдаёт округление FreeType; калибровочные ветки существуют ровно затем, чтобы подменить округление реальным значением Word. Пропорция от 13.61 при переходе 12 → 10 пт даёт примерно 11.34.
|
||||
|
||||
Итог: пока ветки для 10 пт нет, **каждая строка листинга меряется примерно на 3% короче**, и на длинных листингах разъезжаются разрывы страниц.
|
||||
|
||||
Порядок работ:
|
||||
|
||||
1. Добавить ветку `Courier` + размер 10 → `Pt(11.34)`. Лучше сначала подтвердить число замером в Word через COM, а не принимать пропорцию на веру.
|
||||
2. Перевести `tests/__init__.py` (строка 27) с `Pt(12)` на `Pt(10)`, чтобы фикстура совпадала с `style_config.py:305` и табл. 4.1.
|
||||
3. Прогнать `tests/test_paragraph_sizer.py` — серия `test_count_lines_courier1..10` завязана на текущую высоту, ожидаемые значения поедут.
|
||||
|
||||
Не делай пункт 2 без пункта 1 — иначе калиброванная ветка перестанет срабатывать, и замер станет хуже, чем сейчас.
|
||||
|
||||
### `Other_Code/` — 59 файлов, 1158 КБ
|
||||
|
||||
Это была Фаза 4.2 плана, и я её просто не назначил ни одному исполнителю при раскладке работ. Каталог до сих пор полностью в git.
|
||||
|
||||
Ни одного импорта из `md2gost`, `word2md`, `md2latex`, `md2fodt`, `tests/`; не упоминается в `.spec`, `.bat`, `.github/workflows/`, `pyproject.toml`. Единственные ссылки — раздел 12 в `PROJECT.md` и внутренние перекрёстные ссылки самого каталога.
|
||||
|
||||
Команды и три варианта действий расписаны в [git-cleanup.md](git-cleanup.md), раздел 1.
|
||||
|
||||
### Личные документы в корне
|
||||
|
||||
`ТарасовИА_КР_v3.docx` (2326 КБ) и `Практическая_работа_6.docx` (42 КБ) — ФИО прямо в имени файла. Ты сказал, что уберёшь руками; команды и предупреждение про ловушку с `md2gost/Template.docx` в [git-cleanup.md](git-cleanup.md), раздел 2.
|
||||
|
||||
---
|
||||
|
||||
## P2 — открытые вопросы по методичке
|
||||
|
||||
### «С новой страницы» для надписи листинга
|
||||
|
||||
Табл. 4.1 (`docs/reference/method-mirea-2022.txt`, строки 714-715) в блоке «Положение на странице» перечисляет и «Не отрывать от следующего», и «С новой страницы».
|
||||
|
||||
В `md2gost/style_config.py` у стиля `Caption Listing` (строки 278-291) есть `keep_with_next=True`, но нет `page_break_before=True`. Вопрос не праздный: в репозитории есть целая система режимов `--listing-continuation`, так что отсутствие разрыва может быть осознанным выбором. Плюс `Caption.__init__` теперь явно ставит `page_break_before = False`, чтобы не плодить пустые страницы при сочетании с `keep_with_next` — то есть менять придётся согласованно в двух местах.
|
||||
|
||||
Решение нужно принять до того, как кто-то «починит» это по табл. 4.1 в лоб.
|
||||
|
||||
### Правило `heading.empty` ложно срабатывает внутри листингов
|
||||
|
||||
Новое правило в `md2gost/checker/__init__.py` не вырезает fenced-блоки, поэтому одинокая строка `#` внутри блока кода будет засчитана как пустой заголовок. Поведение консистентно с уже существующим `HEADING_RE` в том же файле, то есть это общая для checker'а проблема, а не привнесённая. Чинить имеет смысл сразу для всех проверок заголовков.
|
||||
|
||||
---
|
||||
|
||||
## P3 — не начатое из Фазы 6
|
||||
|
||||
Всё это помечено в плане как опциональное. Ничего из перечисленного не сделано, кроме общего обходчика AST и разбора `word2md/_handle_paragraph`.
|
||||
|
||||
### Разбиение `idef0.py`
|
||||
|
||||
1935 строк (сейчас 74 КБ). God-функции: `_parse_arrow_line` (~177 строк), `_draw_kit_form` (~121), `_route_icom` (~110), `_draw_arrow` (~83), `_layout_arrow_label` (~80).
|
||||
|
||||
Предлагаемая раскладка в пакет `idef0/`: `model.py`, `parse.py`, `layout.py`, `routing.py`, `scene.py`, `draw.py`, `render.py`, с реэкспортом публичного API из `__init__.py` ради тестов. Выигрыша в скорости не даёт, только читаемость.
|
||||
|
||||
Отдельно там же: `_Scene.to_png()` и `_Scene.to_svg()` дважды обходят один и тот же список операций. Можно сгенерировать SVG и растеризовать его в PNG.
|
||||
|
||||
### Разбиение `gui.py`
|
||||
|
||||
1695 строк (82 КБ) в одном классе `Md2GostApp`: главное окно, шесть диалогов, редактор UML-схем на ~270 строк, композер промптов, логика перезаписи файлов, потоки конвертации, загрузка ассетов.
|
||||
|
||||
Раскладка: `gui/windows/`, `gui/widgets.py`, `gui/convert_runner.py`, а `Md2GostApp` оставить тонкой оболочкой.
|
||||
|
||||
### Ленивые импорты
|
||||
|
||||
`md2gost/pipeline.py` (строки 15-19) и `md2gost/gui.py` (строки 11-32) тянут весь конвейер на старте: `docx`, pygments, PIL, lxml. `python -m md2gost --help` платит за это полную цену. Matplotlib и playwright уже ленивые — сделать так же с остальным.
|
||||
|
||||
### Общий сборщик `ConvertRequest`
|
||||
|
||||
`gui.py` (строки 1730-1760) и `__main__.py` (строки 272-317) дублируют маппинг примерно двадцати полей и уже разошлись: дефолт типа документа в GUI `coursework`, в CLI `practice`.
|
||||
|
||||
### Флаг `--open`
|
||||
|
||||
`__main__.py` строки 315-316: `open_when_done=bool(args.debug)`. Открытие готового файла привязано к дебагу, отдельного флага нет, хотя в GUI есть галочка.
|
||||
|
||||
### Скачивание PlantUML блокирует Tk
|
||||
|
||||
`gui.py` строка 1537 — синхронный вызов `fetch_plantuml_jar`, тогда как Graphviz (1576) и Chromium (1622) рядом уже качаются в отдельном потоке.
|
||||
|
||||
---
|
||||
|
||||
## P3 — документация не догоняет код
|
||||
|
||||
### Таблицы модулей в `PROJECT.md` не знают про `mdexport`
|
||||
|
||||
Новый пакет `mdexport` не попал в разделы с картой репозитория и перечнем модулей — исполнитель не владел этим файлом и намеренно его не трогал. Заодно стоит убрать оттуда упоминания удалённых прототипов, если раздел 12 их ещё перечисляет.
|
||||
|
||||
---
|
||||
|
||||
## P4 — замечания исполнителей, оставленные как есть
|
||||
|
||||
- **`uv.lock` в порядке.** `uv lock` отработал с сетью, добавил matplotlib 3.11.1 с транзитивными зависимостями, `uv lock --check` проходит. Лок синхронен и по matplotlib, и по `pywin32` — пересобирать не нужно.
|
||||
- **Playwright закрывается в конце `convert()`.** Это и было целью Фазы 3.4, но у GUI-сценария появилась плата: при каждой следующей конвертации браузер поднимается заново вместо переиспользования. Если mermaid-диаграммы конвертируются часто, стоит подумать про пул с таймаутом.
|
||||
- **Кастомный `--plantuml-jar` другой версии не отражается в ключе кэша.** Префикс использует прибитую константу `PLANTUML_VERSION`. Фингерпринт самого файла закрыл бы дыру, но заставлял бы кэш протухать при каждом появлении и исчезновении jar.
|
||||
- **Версия jar в ключе кэша задевает и Kroki-рендеры**, потому что не-mermaid типы делят один путь кэша между бэкендами. Бамп версии обесценит и записи, отрисованные через Kroki. Это лишняя перерисовка, не более.
|
||||
- **Если одна диаграмма роняет батч-JVM, отбрасывается вся пачка** и каждая диаграмма уходит на свою JVM. Корректно, но стоит одной впустую потраченной JVM.
|
||||
- **`--syntax-highlighting --strict --check` теперь завершается с кодом 1**, потому что новое правило `listing.syntax_highlighting` под `--strict` становится ошибкой. Это ровно то поведение, которое закладывалось, но про него надо помнить в скриптах.
|
||||
- **Playwright изредка печатает `Task was destroyed but it is pending`** на выходе интерпретера при большом комбинированном прогоне. Не воспроизвелось повторно, все тесты проходят, тот же путь закрытия вызывался и до правок.
|
||||
- **Правила игнора `/tmp_*.png` и `/tmp_*.puml`** захватили рабочие черновики `tmp_org_full.*`, `tmp_org_uml.*`, `tmp_arch.*`. Файлы на диске целы, но из `git status` пропали.
|
||||
- **`todo.txt` в корне** остался нетронутым — это твой личный блокнот, я его не трогал и не сливал с этим файлом.
|
||||
|
||||
---
|
||||
|
||||
## Что стоит сделать перед коммитом
|
||||
|
||||
1. `git add mdexport/` — иначе всё сломается на свежем клоне.
|
||||
2. Просмотреть `git diff --cached` и `git diff` — там 34 файла изменений плюс 8095 удалённых строк.
|
||||
3. Прогнать `.venv\Scripts\python.exe -m pytest -q` ещё раз после `git add`.
|
||||
4. Собрать exe одним из `build-exe*.bat` — ни один из билд-скриптов в ходе аудита не проверялся, а `pyproject.toml` и состав пакетов поменялись.
|
||||
@@ -0,0 +1,662 @@
|
||||
# ArchiMate 3.2: гайд по генерации схем (PlantUML + md2gost)
|
||||
|
||||
Учебный гайд по **правильной генерации** диаграмм ArchiMate® 3.2 в PlantUML для отчётов md2gost.
|
||||
Язык — **только 3.2** (документ C226). ArchiMate 4 не используется.
|
||||
|
||||
ArchiMate® — зарегистрированный товарный знак The Open Group. Текст гайда — учебный пересказ корпуса PDF и документации PlantUML, **не** копия спецификации.
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Как рисовать в md2gost](#1-как-рисовать-в-md2gost)
|
||||
2. [Каркас языка 3.2](#2-каркас-языка-32)
|
||||
3. [Каталог элементов → макросы PlantUML](#3-каталог-элементов--макросы-plantuml)
|
||||
4. [Связи](#4-связи)
|
||||
5. [Что с чем связывать](#5-что-с-чем-связывать)
|
||||
6. [Viewpoints](#6-viewpoints)
|
||||
7. [Синтаксис PlantUML](#7-синтаксис-plantuml)
|
||||
8. [Чеклист для ИИ](#8-чеклист-для-ии)
|
||||
9. [Готовые шаблоны](#9-готовые-шаблоны)
|
||||
10. [Антипаттерны](#10-антипаттерны)
|
||||
11. [Источники и границы корпуса](#11-источники-и-границы-корпуса)
|
||||
|
||||
---
|
||||
|
||||
## 1. Как рисовать в md2gost
|
||||
|
||||
Оградка схемы: `` ```uml-archimate `` или `` ```archimate ``.
|
||||
|
||||
В теле пишите **только макросы** Archimate-PlantUML. Без `@startuml`, без `@enduml`, без `!include` — схема `archimate` добавит их сама.
|
||||
|
||||
````markdown
|
||||
%arch1 Слоистая архитектура портала +landscape
|
||||
|
||||
```uml-archimate
|
||||
LAYOUT_TOP_DOWN()
|
||||
|
||||
Business_Actor(student, "Студент")
|
||||
Business_Process(enroll, "Подача заявки")
|
||||
Application_Service(portalSvc, "Портал заявок")
|
||||
Application_Component(portal, "Web-портал")
|
||||
|
||||
Rel_Assignment(student, enroll)
|
||||
Rel_Serving(portalSvc, enroll)
|
||||
Rel_Realization(portal, portalSvc)
|
||||
```
|
||||
````
|
||||
|
||||
| Флаг у `%подписи` | Смысл |
|
||||
|-------------------|--------|
|
||||
| `+landscape` | Альбомная страница под широкий рисунок |
|
||||
| `+listing` | Рядом показать исходник диаграммы |
|
||||
|
||||
Широкие Layered / Application Cooperation почти всегда с `+landscape`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Каркас языка 3.2
|
||||
|
||||
### 2.1. Модель, концепт, элемент, связь
|
||||
|
||||
По sample C226 (§2–3):
|
||||
|
||||
- **Model** — набор концептов.
|
||||
- **Concept** — либо **element**, либо **relationship**.
|
||||
- **Element** — behavior, structure, motivation или composite (абстрактные классы; в модели не рисуются сами по себе).
|
||||
- **Relationship** — связь source → target; класс: structural / dependency / dynamic / other.
|
||||
- **Relationship connector (Junction)** — соединяет две или более связи **одного** типа.
|
||||
|
||||
Язык намеренно мал: «80 % практических случаев», без попытки покрыть всех пользователей сразу (§3.1).
|
||||
|
||||
### 2.2. Три core-слоя
|
||||
|
||||
| Слой | Что показывает |
|
||||
|------|----------------|
|
||||
| **Business** | Бизнес-сервисы для клиентов; процессы и акторы, которые их реализуют |
|
||||
| **Application** | Прикладные сервисы и приложения, поддерживающие бизнес |
|
||||
| **Technology** | ИТ и OT: обработка, хранение, сети; также физика (сооружения, оборудование, материалы, распределительные сети) |
|
||||
|
||||
Структура моделей на слоях похожа: те же виды элементов и связей, разная природа и гранулярность.
|
||||
|
||||
### 2.3. Аспекты (подлежащее — сказуемое — дополнение)
|
||||
|
||||
| Аспект | Роль | Примеры |
|
||||
|--------|------|---------|
|
||||
| **Active structure** | Кто/что действует | Actor, Role, Component, Node, Device |
|
||||
| **Behavior** | Что делается | Process, Function, Interaction, Service, Event |
|
||||
| **Passive structure** | Над чем работают | Business Object, Data Object, Artifact, Material |
|
||||
| **Motivation** | Зачем | Stakeholder, Driver, Goal, Requirement… |
|
||||
| **Composite** | Группировка / место | Grouping, Location, Product |
|
||||
|
||||
На листах Виерды (не стандарт Open Group): синий = активная структура, жёлтый = поведение, зелёный = пассивная. **Официальный ArchiMate бесцветен**; цвета — соглашение инструментов (в PlantUML — палитры слоёв `#Business`, `#Application`, …).
|
||||
|
||||
### 2.4. Full framework
|
||||
|
||||
К core добавляются (van den Berg §2.4 / full framework C226):
|
||||
|
||||
- слой **Strategy**;
|
||||
- слой **Implementation & Migration**;
|
||||
- аспект **Motivation**.
|
||||
|
||||
### 2.5. Два главных межслойных моста (§3.3)
|
||||
|
||||
1. **Serving** (раньше *used by*): сервис (или элемент) **предоставляет функциональность** другому. Сервис нижнего/соседнего слоя обслуживает потребителя. Сервис может обслуживать и элемент **того же** слоя.
|
||||
2. **Realization**: более конкретное (часто ниже) **реализует** более абстрактное (часто выше): например приложение реализует прикладной сервис, сервис/процесс реализует требование или capability.
|
||||
|
||||
Каноническая цепочка внутри слоя:
|
||||
|
||||
```text
|
||||
Active structure —Assignment→ internal behavior (Process/Function)
|
||||
internal behavior —Realization→ Service (external behavior)
|
||||
Service —Serving→ consumer (Role / Process / Component …)
|
||||
behavior —Access→ passive structure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Каталог элементов → макросы PlantUML
|
||||
|
||||
Определения — **N221** (Reference Cards). Макросы — Archimate-PlantUML 3.2.1.
|
||||
Формат: `Category_ElementName(alias, "Подпись")`.
|
||||
|
||||
Alias — латиница (`student`, `crm`). Не используйте `end`, `start`, `group` как alias.
|
||||
|
||||
### 3.1. Motivation
|
||||
|
||||
| Элемент | Макрос | Определение (N221) |
|
||||
|---------|--------|-------------------|
|
||||
| Stakeholder | `Motivation_Stakeholder` | Роль индивида, команды или организации, заинтересованной в эффектах архитектуры |
|
||||
| Driver | `Motivation_Driver` | Внешнее/внутреннее условие, побуждающее задать цели и изменения |
|
||||
| Assessment | `Motivation_Assessment` | Результат анализа положения дел относительно driver |
|
||||
| Goal | `Motivation_Goal` | Высокоуровневое намерение / желаемое конечное состояние |
|
||||
| Outcome | `Motivation_Outcome` | Конечный результат / эффект некоторого положения дел |
|
||||
| Principle | `Motivation_Principle` | Общее свойство, применимое к любой системе в контексте |
|
||||
| Requirement | `Motivation_Requirement` | Потребность: свойство конкретной системы в архитектуре |
|
||||
| Constraint | `Motivation_Constraint` | Ограничение на архитектуру, реализацию или процесс внедрения |
|
||||
| Meaning | `Motivation_Meaning` | Знание/интерпретация концепта в контексте |
|
||||
| Value | `Motivation_Value` | Относительная ценность / полезность концепта |
|
||||
|
||||
### 3.2. Strategy
|
||||
|
||||
| Элемент | Макрос | Определение (N221) |
|
||||
|---------|--------|-------------------|
|
||||
| Resource | `Strategy_Resource` | Актив, которым владеют или управляют |
|
||||
| Capability | `Strategy_Capability` | Способность, которой обладает активная структура |
|
||||
| Value Stream | `Strategy_ValueStream` | Последовательность активностей, создающая результат для клиента/стейкхолдера |
|
||||
| Course of Action | `Strategy_CourseOfAction` | Подход/план настройки capabilities и resources для достижения цели |
|
||||
|
||||
### 3.3. Business
|
||||
|
||||
| Элемент | Макрос | Аспект | Определение (N221) |
|
||||
|---------|--------|--------|-------------------|
|
||||
| Business Actor | `Business_Actor` | Active | Сущность, способная выполнять поведение |
|
||||
| Business Role | `Business_Role` | Active | Ответственность за поведение; роль актора |
|
||||
| Business Collaboration | `Business_Collaboration` | Active | Агрегат ≥2 внутренних active structure для коллективного поведения |
|
||||
| Business Interface | `Business_Interface` | Active | Точка доступа, где бизнес-сервис доступен среде |
|
||||
| Business Process | `Business_Process` | Behavior | Последовательность бизнес-поведения к конкретному результату |
|
||||
| Business Function | `Business_Function` | Behavior | Набор поведения по критериям (ресурсы/компетенции) |
|
||||
| Business Interaction | `Business_Interaction` | Behavior | Коллективное поведение ≥2 акторов/ролей/коллабораций |
|
||||
| Business Event | `Business_Event` | Behavior | Изменение состояния на бизнес-уровне |
|
||||
| Business Service | `Business_Service` | Behavior | Явно определённое поведение, доступное среде |
|
||||
| Business Object | `Business_Object` | Passive | Концепт предметной области |
|
||||
| Contract | `Business_Contract` | Passive | Соглашение провайдер–потребитель (права/обязанности, параметры) |
|
||||
| Representation | `Business_Representation` | Passive | Воспринимаемая форма информации бизнес-объекта |
|
||||
| Product | `Business_Product` | Composite | Согласованный набор сервисов/пассивных элементов + contract |
|
||||
|
||||
### 3.4. Application
|
||||
|
||||
| Элемент | Макрос | Аспект | Определение (N221) |
|
||||
|---------|--------|--------|-------------------|
|
||||
| Application Component | `Application_Component` | Active | Инкапсуляция прикладной функциональности, модульная и заменяемая |
|
||||
| Application Collaboration | `Application_Collaboration` | Active | Агрегат ≥2 application internal active structure |
|
||||
| Application Interface | `Application_Interface` | Active | Точка доступа к application services |
|
||||
| Application Function | `Application_Function` | Behavior | Автоматизированное поведение компонента |
|
||||
| Application Interaction | `Application_Interaction` | Behavior | Коллективное поведение ≥2 компонентов |
|
||||
| Application Process | `Application_Process` | Behavior | Последовательность прикладного поведения к результату |
|
||||
| Application Event | `Application_Event` | Behavior | Изменение состояния приложения |
|
||||
| Application Service | `Application_Service` | Behavior | Явно определённое внешнее прикладное поведение |
|
||||
| Data Object | `Application_DataObject` | Passive | Данные, структурированные для обработки |
|
||||
|
||||
### 3.5. Technology (+ Physical в гл. Technology 3.2)
|
||||
|
||||
| Элемент | Макрос | Определение (N221) |
|
||||
|---------|--------|-------------------|
|
||||
| Node | `Technology_Node` | Вычислительный/физический ресурс, хостящий другие ресурсы |
|
||||
| Device | `Technology_Device` | Физический ИТ-ресурс для хранения/исполнения ПО и артефактов |
|
||||
| System Software | `Technology_SystemSoftware` | ПО среды исполнения/хранения |
|
||||
| Technology Collaboration | `Technology_Collaboration` | Агрегат ≥2 technology internal active structure |
|
||||
| Technology Interface | `Technology_Interface` | Точка доступа к technology services |
|
||||
| Path | `Technology_Path` | Связь между ≥2 technology internal active structure для обмена |
|
||||
| Communication Network | `Technology_CommunicationNetwork` | Структуры/поведение для передачи данных |
|
||||
| Technology Function | `Technology_Function` | Набор технологического поведения |
|
||||
| Technology Process | `Technology_Process` | Последовательность технологического поведения |
|
||||
| Technology Interaction | `Technology_Interaction` | Коллективное technology-поведение |
|
||||
| Technology Event | `Technology_Event` | Изменение технологического состояния |
|
||||
| Technology Service | `Technology_Service` | Явно определённое внешнее technology-поведение |
|
||||
| Artifact | `Technology_Artifact` | Данные/файл, используемый или порождаемый разработкой/эксплуатацией |
|
||||
| Equipment | `Physical_Equipment` | Физические машины/инструменты для материи |
|
||||
| Facility | `Physical_Facility` | Физическая структура или окружение |
|
||||
| Distribution Network | `Physical_DistributionNetwork` | Физическая сеть транспортировки материи/энергии |
|
||||
| Material | `Physical_Material` | Осязаемая материя или энергия |
|
||||
|
||||
В 3.2 Device / System Software / Facility / Equipment — **не** подтипы Node, а technology internal active structure с composition/aggregation к Node (changelog C226).
|
||||
|
||||
### 3.6. Implementation & Migration
|
||||
|
||||
| Элемент | Макрос | Определение (N221) |
|
||||
|---------|--------|-------------------|
|
||||
| Work Package | `Implementation_WorkPackage` | Серия действий с результатом в сроках и ресурсах |
|
||||
| Deliverable | `Implementation_Deliverable` | Точно определённый результат work package |
|
||||
| Implementation Event | `Implementation_Event` | Изменение состояния, связанное с внедрением/миграцией |
|
||||
| Plateau | `Implementation_Plateau` | Относительно стабильное состояние архитектуры на период |
|
||||
| Gap | `Implementation_Gap` | Различие между двумя plateaus |
|
||||
|
||||
### 3.7. Composite и Junction
|
||||
|
||||
| Элемент | Макрос | Определение (N221) |
|
||||
|---------|--------|-------------------|
|
||||
| Grouping | `Grouping` / `Other_Grouping` / `Group` | Агрегирует/композирует концепты по общему признаку |
|
||||
| Location | `Other_Location` / `Business_Location` | Место, где расположены или выполняются концепты |
|
||||
| And Junction | `Junction_And` | Логическое И для связей одного типа |
|
||||
| Or Junction | `Junction_Or` | Логическое ИЛИ для связей одного типа |
|
||||
|
||||
Grouping и Junction допустимы **в любом** viewpoint (спецификация).
|
||||
|
||||
### 3.8. Когда какой элемент поведения
|
||||
|
||||
| Нужно | Берите |
|
||||
|-------|--------|
|
||||
| Последовательность шагов к результату | `*_Process` |
|
||||
| Группа поведения «изнутри» по компетенциям/ресурсам | `*_Function` |
|
||||
| Поведение коллаборации (≥2 участников) | `*_Interaction` |
|
||||
| Внешне видимое поведение (чёрный ящик) | `*_Service` |
|
||||
| Момент / смена состояния | `*_Event` |
|
||||
|
||||
**Не смешивайте слои:** `Business_Process` ≠ `Application_Process` ≠ `Technology_Process`. Слой задаётся типом элемента, не подписью.
|
||||
|
||||
---
|
||||
|
||||
## 4. Связи
|
||||
|
||||
### 4.1. Одиннадцать типов (N221) + PlantUML
|
||||
|
||||
| Тип | Класс | Смысл (N221) | Макрос | Нотация PlantUML |
|
||||
|-----|-------|--------------|--------|------------------|
|
||||
| Composition | Structural | Элемент **состоит из** других; часть не существует без целого | `Rel_Composition` | `*--` |
|
||||
| Aggregation | Structural | Элемент **объединяет** другие; части могут жить отдельно | `Rel_Aggregation` | `o--` |
|
||||
| Assignment | Structural | Ответственность / выполнение поведения / хранение / исполнение | `Rel_Assignment` | `@-->>` |
|
||||
| Realization | Structural | Критическая роль в создании/достижении более абстрактного | `Rel_Realization` | `~~|>` |
|
||||
| Serving | Dependency | Предоставляет функциональность другому | `Rel_Serving` | `-->` |
|
||||
| Access | Dependency | Поведение/active structure наблюдает или действует на passive | `Rel_Access` / `_r` / `_w` / `_rw` | `~~` / `<-~` / `~->` / `<~~>` |
|
||||
| Influence | Dependency | Влияет на реализацию/достижение motivation-элемента (`+/-`) | `Rel_Influence` | `..>` |
|
||||
| Triggering | Dynamic | Временная/причинная активация | `Rel_Triggering` | `-->>` |
|
||||
| Flow | Dynamic | Передача чего-либо | `Rel_Flow` | `..>>` |
|
||||
| Specialization | Other | Является разновидностью | `Rel_Specialization` | `--|>` |
|
||||
| Association | Other | Неспецифичная связь (или нет другого типа) | `Rel_Association` / `_dir` | `--` / `--\` |
|
||||
| Junction | Connector | Соединяет связи **одного** типа | `Junction_And` / `Junction_Or` | circle |
|
||||
|
||||
Направление на схеме: `Rel_Type_Up` / `_Down` / `_Left` / `_Right` (например `Rel_Serving_Up(svc, proc)`).
|
||||
|
||||
### 4.2. Направления (частые ошибки)
|
||||
|
||||
| Связь | Откуда → куда |
|
||||
|-------|----------------|
|
||||
| **Serving** | Поставщик (часто Service) → **потребитель** |
|
||||
| **Realization** | Более конкретное → более абстрактное |
|
||||
| **Composition / Aggregation** | Целое → часть |
|
||||
| **Assignment** | Active structure → behavior (или ресурс хранения → artifact) |
|
||||
| **Access** | Behavior (или active) → **passive**; тип: read / write / read-write |
|
||||
| **Triggering** | Предшественник → следующий |
|
||||
| **Flow** | Источник передачи → приёмник |
|
||||
| **Influence** | Влияющий → motivation-элемент (сила `++`/`+`/`0`/`-`/`--` в подписи) |
|
||||
| **Specialization** | Специализация → общий тип |
|
||||
|
||||
На листах: realization — «вверх» по абстракции; serving — к тому, кого обслуживают. Access всегда к объекту, независимо от отрисовки стрелки.
|
||||
|
||||
### 4.3. Правила с листов (метамодель ядра)
|
||||
|
||||
- Любой элемент может **compose/aggregate** элементы **того же класса** (на обзорных диаграммах часто не рисуют).
|
||||
- Внутренние поведения одного слоя могут compose/aggregate друг друга (Function агрегирует Process).
|
||||
- **Association** формально возможна между любыми классами — но на схеме используйте её только когда нет более точного типа.
|
||||
- Collaboration ведёт себя как соответствующий internal active structure.
|
||||
- Stakeholder — Association с любым элементом; элементы мотивации (кроме Stakeholder) — Influence друг на друга.
|
||||
|
||||
### 4.4. Чего нет в корпусе
|
||||
|
||||
Полная **Appendix B** (матрица всех допустимых пар и derivation) в sample PDF **обрезана**. Ниже — практические цепочки из §3.3, N221, листов и типичных viewpoint’ов. Не выдавайте их за полную нормативную матрицу B.
|
||||
|
||||
---
|
||||
|
||||
## 5. Что с чем связывать
|
||||
|
||||
### 5.1. Канонические цепочки
|
||||
|
||||
```text
|
||||
# Внутри слоя
|
||||
Business_Actor/Role Rel_Assignment Business_Process/Function
|
||||
Business_Process Rel_Realization Business_Service
|
||||
Business_Process Rel_Access_* Business_Object
|
||||
Business_Event Rel_Triggering Business_Process
|
||||
|
||||
Application_Component Rel_Assignment Application_Function/Process
|
||||
Application_Function Rel_Realization Application_Service
|
||||
Application_Service Rel_Serving Business_Process/Role
|
||||
Application_Component Rel_Access_* Application_DataObject
|
||||
Application_Component Rel_Composition Application_Component
|
||||
|
||||
Technology_Node Rel_Composition Technology_Device / SystemSoftware
|
||||
Technology_* Rel_Assignment Technology_Function/Process
|
||||
Technology_Function Rel_Realization Technology_Service
|
||||
Technology_Service Rel_Serving Application_Component
|
||||
Technology_Artifact Rel_Realization Application_DataObject
|
||||
Application_DataObject Rel_Realization Business_Object
|
||||
|
||||
# Стратегия / мотивация
|
||||
Business_Process / Application_Service Rel_Realization Strategy_Capability
|
||||
Application_Service / Component Rel_Realization Motivation_Requirement
|
||||
Motivation_Driver Rel_Association Motivation_Stakeholder
|
||||
Motivation_Assessment Rel_Association Motivation_Driver
|
||||
Motivation_Goal Rel_Realization Motivation_Outcome
|
||||
Motivation_Requirement Rel_Specialization Motivation_Constraint # или наоборот по смыслу «ограничение как вид требования» — лучше Constraint отдельно + Influence/Association
|
||||
|
||||
# Миграция
|
||||
Implementation_WorkPackage Rel_Realization / Aggregation Implementation_Deliverable
|
||||
Implementation_Plateau Rel_Aggregation core elements
|
||||
Implementation_Gap Rel_Association between plateaus
|
||||
```
|
||||
|
||||
### 5.2. Запреты для генерации
|
||||
|
||||
1. Не `Rel_Assignment` от Data Object / Business Object к процессу.
|
||||
2. Не `Rel_Realization` от Component сразу к Process — нужен Function/Process того же слоя, затем Realization к Service.
|
||||
3. Не `Rel_Serving` «потребитель → сервис» (перевёрнуто).
|
||||
4. Не подменять Access потоком Flow «потому что данные».
|
||||
5. Не рисовать `Business_Process` вместо `Application_Process`, если исполнитель — компонент.
|
||||
6. Не ставить Association на все рёбра «на всякий случай».
|
||||
7. Не считать Device подтипом Node без composition/aggregation (3.2).
|
||||
|
||||
---
|
||||
|
||||
## 6. Viewpoints
|
||||
|
||||
**Один рисунок = один viewpoint.** Не сваливайте мотивацию, бизнес, приложения и ЦОД на один холст без нужды (Layered — исключение, но всё равно ограничивайте число элементов).
|
||||
|
||||
Имена — из оглавления sample C226, Appendix C:
|
||||
|
||||
### 6.1. Basic (C.1)
|
||||
|
||||
| Viewpoint | Фокус | Типичные элементы |
|
||||
|-----------|-------|-------------------|
|
||||
| Organization | Структура орг. единиц | Actor, Role, Collaboration, Location, Interface |
|
||||
| Application Structure | Внутренняя структура приложений | Component, Collaboration, Interface, Data Object |
|
||||
| Information Structure | Данные/информация | Business Object, Representation, Data Object, Artifact, Meaning |
|
||||
| Technology | Инфраструктура | Node, Device, System Software, Network, Path, Artifact… |
|
||||
| Layered | Обзор слоёв | Набор из Business + Application + Technology + serving/realization |
|
||||
| Physical | Физический мир | Equipment, Facility, Distribution Network, Material + tech |
|
||||
| Product | Ценность продукта | Product, Contract, Services, Interfaces |
|
||||
| Application Usage | Приложения в бизнес-контексте | Process/Function/Service бизнес + application |
|
||||
| Technology Usage | Технологии для приложений | Application + Technology services/nodes |
|
||||
| Business Process Cooperation | Связь процессов | Processes, Events, Services, Roles, supporting apps |
|
||||
| Application Cooperation | Связь приложений | Components, Services, Interfaces, Data, Flow/Serving |
|
||||
| Service Realization | Как сервисы реализованы | Service ← Process/Function ← Active structure |
|
||||
| Implementation and Deployment | Развёртывание | Application + Artifact + Node/Device |
|
||||
|
||||
### 6.2. Motivation (C.2)
|
||||
|
||||
Stakeholder · Goal Realization · Requirements Realization · Motivation — Driver/Assessment/Goal/Outcome/Principle/Requirement/Constraint/Value/Meaning и Realization/Influence к core.
|
||||
|
||||
### 6.3. Strategy (C.3)
|
||||
|
||||
Strategy · Capability Map · Value Stream · Outcome Realization · Resource Map.
|
||||
|
||||
### 6.4. Implementation & Migration (C.4)
|
||||
|
||||
Project · Migration · Implementation and Migration — Work Package, Deliverable, Plateau, Gap, Event.
|
||||
|
||||
### 6.5. Макет
|
||||
|
||||
- Организация / структура: `LAYOUT_TOP_DOWN()` или вложенность `{ }`.
|
||||
- Процессы слева направо: `LAYOUT_LEFT_RIGHT()` + `Rel_*_Right`.
|
||||
- Layered: бизнес сверху, technology снизу; serving вверх (`Rel_Serving_Up`), realization вверх к абстракции.
|
||||
- Широкие схемы: `%id … +landscape`.
|
||||
|
||||
Пример композиции (van den Berg): сначала **Motivation viewpoint** (риски/цели), затем отдельные схемы процессов с **Junction** на ветвлениях восстановления.
|
||||
|
||||
---
|
||||
|
||||
## 7. Синтаксис PlantUML
|
||||
|
||||
Документация: [plantuml.com/archimate-diagram](https://plantuml.com/archimate-diagram). Stdlib: Archimate-PlantUML **3.2.1**.
|
||||
|
||||
### 7.1. Рекомендуемый путь (схема md2gost)
|
||||
|
||||
```text
|
||||
LAYOUT_TOP_DOWN() ' или LAYOUT_LEFT_RIGHT()
|
||||
Business_Actor(a, "Имя")
|
||||
Application_Service(s, "Сервис")
|
||||
Rel_Serving_Up(s, a)
|
||||
```
|
||||
|
||||
Include и `@startuml` добавляет схема `archimate`.
|
||||
|
||||
### 7.2. Низкоуровневый keyword (без макросов)
|
||||
|
||||
Только в `` ```uml ``, если stdlib недоступен:
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
archimate #Business "Подача заявки" as p <<business-process>>
|
||||
archimate #Application "Портал" as c <<application-component>>
|
||||
c --> p
|
||||
@enduml
|
||||
```
|
||||
|
||||
Sprites: `<<$archimate/business-process>>` или `jar:archimate/...`.
|
||||
**Не смешивайте** макросы и сырой `archimate` на одной схеме.
|
||||
|
||||
### 7.3. Темы
|
||||
|
||||
```plantuml
|
||||
!theme archimate-standard from <archimate/themes>
|
||||
' archimate-alternate | saturated | lowsaturation | handwriting
|
||||
```
|
||||
|
||||
### 7.4. Nesting и special shapes
|
||||
|
||||
```plantuml
|
||||
Business_Product(prod, "Тариф") {
|
||||
Business_Service(s1, "Подключение")
|
||||
Business_Contract(c1, "Оферта")
|
||||
}
|
||||
```
|
||||
|
||||
Для special shapes (`$ARCH_SPECIAL_SHAPES`) у Service/Actor/Value/ValueStream при вложении: `$nest=%true()`.
|
||||
|
||||
### 7.5. Layout helpers
|
||||
|
||||
`Lay_U` / `Lay_D` / `Lay_L` / `Lay_R` — скрытые связи для выравнивания.
|
||||
`LAYOUT_AS_SKETCH()` — черновик (не для финального ГОСТ-рисунка).
|
||||
|
||||
### 7.6. Sequence с ArchiMate
|
||||
|
||||
Только если явно просят sequence: `!global $ARCH_SEQUENCE_SUPPORT = %true()` до include. Это **не** замена viewpoint.
|
||||
|
||||
### 7.7. List sprites
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
listsprite
|
||||
@enduml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Чеклист для ИИ
|
||||
|
||||
1. Версия языка: **ArchiMate 3.2**; макросы слоя (`Business_*`, `Application_*`, …).
|
||||
2. Оградка `` ```uml-archimate ``; без `@startuml` / `!include`.
|
||||
3. Выбрать **один** viewpoint; 8–18 элементов на рисунок.
|
||||
4. Сначала все элементы, потом все `Rel_*`.
|
||||
5. Alias латиницей; подписи по-русски в кавычках; длинные имена с `\n`.
|
||||
6. Serving от сервиса к потребителю; Realization от конкретного к абстрактному.
|
||||
7. Поведение того же слоя, что и исполнитель (Assignment).
|
||||
8. Не UML class/component, не C4 `Person`, не BPMN `XOR`, не mxgraph.
|
||||
9. Не Association «по умолчанию».
|
||||
10. Для отчёта: `%id Название` (+ `+landscape` если широко).
|
||||
|
||||
---
|
||||
|
||||
## 9. Готовые шаблоны
|
||||
|
||||
Тела для `` ```uml-archimate `` (без обёртки).
|
||||
|
||||
### 9.1. Organization
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Business_Actor(uni, "Университет") {
|
||||
Business_Actor(dean, "Деканат")
|
||||
Business_Actor(it, "Управление ИТ")
|
||||
}
|
||||
Business_Role(student, "Студент")
|
||||
Business_Role(clerk, "Методист")
|
||||
Rel_Assignment(dean, clerk)
|
||||
Rel_Assignment(uni, student)
|
||||
```
|
||||
|
||||
### 9.2. Service Realization
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Business_Role(applicant, "Абитуриент")
|
||||
Business_Process(apply, "Подача заявления")
|
||||
Business_Service(applySvc, "Приём заявлений")
|
||||
Application_Service(webSvc, "Веб-приёмная")
|
||||
Application_Component(lk, "Личный кабинет")
|
||||
Application_Function(submitFn, "Отправка формы")
|
||||
|
||||
Rel_Assignment(applicant, apply)
|
||||
Rel_Realization(apply, applySvc)
|
||||
Rel_Serving(webSvc, apply)
|
||||
Rel_Assignment(lk, submitFn)
|
||||
Rel_Realization(submitFn, webSvc)
|
||||
```
|
||||
|
||||
### 9.3. Application Cooperation
|
||||
|
||||
```plantuml
|
||||
LAYOUT_LEFT_RIGHT()
|
||||
Application_Component(crm, "CRM")
|
||||
Application_Component(billing, "Биллинг")
|
||||
Application_Service(crmApi, "API клиентов")
|
||||
Application_DataObject(client, "Карточка клиента")
|
||||
|
||||
Rel_Realization(crm, crmApi)
|
||||
Rel_Serving(crmApi, billing)
|
||||
Rel_Access_rw(crm, client)
|
||||
Rel_Access_r(billing, client)
|
||||
Rel_Flow(crm, billing, "событие оплаты")
|
||||
```
|
||||
|
||||
### 9.4. Layered
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Business_Process(sale, "Оформление заказа")
|
||||
Business_Object(order, "Заказ")
|
||||
Application_Service(shopSvc, "Интернет-магазин")
|
||||
Application_Component(shop, "ShopApp")
|
||||
Technology_Service(dbSvc, "СУБД")
|
||||
Technology_Node(dbHost, "DB Host")
|
||||
Technology_SystemSoftware(pg, "PostgreSQL")
|
||||
|
||||
Rel_Access_w(sale, order)
|
||||
Rel_Serving_Up(shopSvc, sale)
|
||||
Rel_Realization(shop, shopSvc)
|
||||
Rel_Serving_Up(dbSvc, shop)
|
||||
Rel_Assignment(dbHost, dbSvc)
|
||||
Rel_Composition(dbHost, pg)
|
||||
```
|
||||
|
||||
### 9.5. Motivation / Requirements Realization
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Motivation_Stakeholder(rector, "Ректор")
|
||||
Motivation_Driver(comp, "Конкуренция вузов")
|
||||
Motivation_Goal(digital, "Цифровизация приёма")
|
||||
Motivation_Requirement(online, "Заявление онлайн 24/7")
|
||||
Motivation_Constraint(152fz, "152-ФЗ")
|
||||
Application_Service(portal, "Портал абитуриента")
|
||||
|
||||
Rel_Association(rector, comp)
|
||||
Rel_Association(comp, digital)
|
||||
Rel_Realization(online, digital)
|
||||
Rel_Influence(152fz, online, "-")
|
||||
Rel_Realization(portal, online)
|
||||
```
|
||||
|
||||
### 9.6. Implementation & Migration
|
||||
|
||||
```plantuml
|
||||
LAYOUT_LEFT_RIGHT()
|
||||
Implementation_Plateau(asIs, "AS-IS 2025")
|
||||
Implementation_Plateau(toBe, "TO-BE 2026")
|
||||
Implementation_Gap(gap1, "Нет единого ЛК")
|
||||
Implementation_WorkPackage(wp, "Проект Единый ЛК")
|
||||
Implementation_Deliverable(d1, "Релиз 1.0")
|
||||
Implementation_Event(goLive, "Go-Live")
|
||||
|
||||
Rel_Association(gap1, asIs)
|
||||
Rel_Association(gap1, toBe)
|
||||
Rel_Realization(wp, d1)
|
||||
Rel_Triggering(d1, goLive)
|
||||
Rel_Triggering(goLive, toBe)
|
||||
```
|
||||
|
||||
### 9.7. Technology Usage
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Application_Component(api, "API Gateway")
|
||||
Technology_Node(k8s, "Kubernetes")
|
||||
Technology_Device(node1, "Worker x86")
|
||||
Technology_SystemSoftware(os, "Linux")
|
||||
Technology_CommunicationNetwork(net, "Cluster CNI")
|
||||
Technology_Artifact(image, "api:1.2.img")
|
||||
|
||||
Rel_Serving_Up(k8s, api)
|
||||
Rel_Composition(k8s, node1)
|
||||
Rel_Composition(node1, os)
|
||||
Rel_Association(node1, net)
|
||||
Rel_Assignment(node1, image)
|
||||
```
|
||||
|
||||
### 9.8. Process с Junction (в духе van den Berg)
|
||||
|
||||
```plantuml
|
||||
LAYOUT_LEFT_RIGHT()
|
||||
Business_Event(incident, "Инцидент")
|
||||
Business_Process(triage, "Триаж")
|
||||
Junction_Or(j1, "")
|
||||
Business_Process(restore, "Восстановление КФ")
|
||||
Business_Process(investigate, "Расследование")
|
||||
Business_Process(close, "Закрытие")
|
||||
|
||||
Rel_Triggering(incident, triage)
|
||||
Rel_Triggering(triage, j1)
|
||||
Rel_Triggering(j1, restore)
|
||||
Rel_Triggering(j1, investigate)
|
||||
Rel_Triggering(restore, close)
|
||||
Rel_Triggering(investigate, close)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Антипаттерны
|
||||
|
||||
| Плохо | Почему | Как надо |
|
||||
|-------|--------|----------|
|
||||
| Все связи `Rel_Association` | Теряется семантика | Serving / Realization / Assignment / Access |
|
||||
| `Application_Component` вместо `Business_Actor` | Разные аспекты и слои | Actor/Role для людей/орг. единиц |
|
||||
| Business Process «внутри» компонента без Assignment | Слои смешаны | Process на бизнес-слое; Serving от Application Service |
|
||||
| Serving от Role к Service | Направление наоборот | Service → Role/Process |
|
||||
| Realization Component → Process | Пропущен внутренний behavior | Component → Function → Service |
|
||||
| Flow вместо Access к Data Object | Flow — передача, Access — работа с объектом | `Rel_Access_r/w/rw` |
|
||||
| Один Layered на 40+ элементов | Нечитаемо | Несколько viewpoint’ов |
|
||||
| C4 `Person` / UML class в archimate-блоке | Другой язык | Только макросы ArchiMate |
|
||||
| Generic «Process» без слоя | В 3.2 слоя обязательны | `Business_Process` / `Application_Process` / … |
|
||||
|
||||
---
|
||||
|
||||
## 11. Источники и границы корпуса
|
||||
|
||||
### Норматив (при конфликте — сверху вниз)
|
||||
|
||||
1. **n221p.pdf** — ArchiMate 3.2 Reference Cards (N221): определения элементов и связей.
|
||||
2. **978940180955C_SMPL.pdf** (+ дубль SMPL-1) — sample C226: гл. 1–2, начало гл. 3, оглавление (в т.ч. список viewpoints Appendix C).
|
||||
3. **archimate-sheets-ru-20230805-s.pdf** — листы метамодели 3.2 (Виерда / Ефремов): прямые связи, цвета аспектов на листах. Описания элементов на листах **не** от Open Group — для формулировок «что такое X» побеждает N221.
|
||||
|
||||
### Синтаксис рисунка
|
||||
|
||||
- [plantuml.com/archimate-diagram](https://plantuml.com/archimate-diagram)
|
||||
- Stdlib Archimate-PlantUML 3.2.1 (`!include <archimate/Archimate>`)
|
||||
|
||||
### Примеры композиции (не словарь языка)
|
||||
|
||||
- hospital ZiRA preview (inkijkexemplaar) — reference architecture / C226.
|
||||
- **vandenBerg_MA_EEMCS.pdf** — §2.4 framework, motivational viewpoint, Junction в процессах BCM/DR.
|
||||
|
||||
### Не правила ArchiMate
|
||||
|
||||
- Article_37 (FEM), paper_41 (OntoUML/COVO), FASD (UML атак), 419 (единое окно), **2659430** (Packt Practical Cybersecurity Architecture — процесс кибер-архитектуры, не спецификация 3.2).
|
||||
|
||||
### Ограничения
|
||||
|
||||
- Полная Appendix B в sample отсутствует — матрица пар здесь практическая, не нормативная таблица B.5.
|
||||
- PDF спецификации в git не хранятся.
|
||||
- Старый PlantUML/Kroki без stdlib 3.2: fallback — `` ```uml `` + keyword `archimate`.
|
||||
- Пользовательский `md2gost.schemes.json`: встроенные схемы с `author: md2gost` синхронизируются с бандлом; свои правки (другой author) не затираются.
|
||||
|
||||
---
|
||||
|
||||
См. также: [schemes.md](schemes.md), меню **Шаблоны UML**, **Справка → Промпт для ИИ** (схема `archimate`).
|
||||
@@ -0,0 +1,243 @@
|
||||
# Отчёт по аудиту и оптимизации
|
||||
|
||||
Проведён 9-10 сентября 2026. Незакрытые задачи — в [TODO.md](TODO.md), команды ручной чистки git — в [git-cleanup.md](git-cleanup.md).
|
||||
|
||||
**Ничего не закоммичено.** Всё лежит в staging и рабочем дереве под ревью.
|
||||
|
||||
## Итог одной таблицей
|
||||
|
||||
| | Было | Стало |
|
||||
|---|---|---|
|
||||
| Замер страницы из 5 абзацев | 80.06 мс | 18.08 мс |
|
||||
| Конструирований `Font` за проход | 19 | 0 |
|
||||
| Вызовов `get_text_width` за проход | 814 | 114 |
|
||||
| Конвертация формулы | 13.29 мс | 0.66 мс |
|
||||
| PlantUML, 4 диаграммы, PNG | 4.99 с | 1.25 с |
|
||||
| PlantUML, 4 диаграммы, SVG | 9.02 с | 2.33 с |
|
||||
| Запусков JVM на документ | N (2N в SVG) | 1 (2 в SVG) |
|
||||
| Сквозная конвертация документа | — | на 10-23% быстрее |
|
||||
| Тесты | 54 passed | 320 passed, 174 subtests |
|
||||
|
||||
Объём изменений: 34 файла правок (+1552 / −698), плюс 34 файла удалений (−8095 строк).
|
||||
|
||||
---
|
||||
|
||||
## 1. Исправлено три бага вывода
|
||||
|
||||
Работа шла строго по TDD: сначала три падающих теста, прогон с 9 падениями по ожидаемым причинам, затем правки. Каждый фикс привязан к пункту методички МИРЭА (`docs/reference/method-mirea-2022.txt`).
|
||||
|
||||
### Гиперссылка дублировала свой текст
|
||||
|
||||
`md2gost/renderable/paragraph.py`, строка 44. В `Link.add_run` цикл по сегментам присваивал `docx_run.text = text` вместо `part`, поэтому ссылка с дефисом выводилась полным текстом столько раз, сколько в ней сегментов.
|
||||
|
||||
Красный тест показал это нагляднее всего на интервале: `['100', '150'] != ['100-150', '100-150']`. На сложном слове: `'вопервых' != 'во-первыхво-первых'`.
|
||||
|
||||
Опечатка при копировании из `Paragraph.add_run`, где на строке 104 тот же цикл написан правильно.
|
||||
|
||||
Обоснование: строки 373-376 методички — дефис не отделяется пробелами и используется в сложных словах («во-первых», «физико-математический») и интервалах («100-150 Мб»), поэтому слово не должно рваться при переносе. Ради этого и вставляется отдельный элемент `w:noBreakHyphen` вместо текстового дефиса.
|
||||
|
||||
### Курсив в подсветке синтаксиса не доезжал до XML
|
||||
|
||||
`md2gost/renderable/listing.py`, строка 36. Выражение `style["italic"] in style or None` проверяло, есть ли значение `True`/`False` среди **ключей** словаря стиля. Всегда ложно, курсив не применялся никогда.
|
||||
|
||||
Здесь пришлось разбираться, что вообще считать правильным поведением. Табл. 4.1 методички (строки 716-721) требует для содержимого листинга `Courier New / Начертание Обычный / 10 пт / Цвет Авто (чёрный)`. Стили в `style_config.py:303-314` этому соответствуют, но `DocxParagraphPygmentsFormatter` перекрывает их на уровне run'а, навешивая полужирный и цвет. То есть подсветка как таковая противоречит методичке, а сломанный курсив лишь случайно оказывался ближе к требованию.
|
||||
|
||||
Решение в два шага: выражение исправлено на `style.get("italic") or None`, чтобы флаг делал то, что обещает, и добавлено правило `listing.syntax_highlighting` в checker — предупреждение по умолчанию, ошибка под `--strict`. Флаг выключен по умолчанию, так что это осознанное отклонение, но пользователь узнаёт о нём из отчёта, а не из замечаний преподавателя.
|
||||
|
||||
Заодно добавлен регрессионный страж: при выключенном флаге ни один run в стиле `Code` не должен задавать `w:b`, `w:i` или `w:color`.
|
||||
|
||||
### Пустой заголовок ронял парсер
|
||||
|
||||
`md2gost/extended_markdown/heading.py`, строка 23. `inline_body[0] == "*"` давал `IndexError` на строке `#` без текста. Заменено на `startswith`.
|
||||
|
||||
Плюс правило `heading.empty` в checker'е: пустой заголовок дал бы пронумерованный безымянный раздел в автособираемом содержании. Обоснование — строка 156 методички: «разделы, подразделы, пункты имеют заголовки, четко и кратко отражающие их содержание».
|
||||
|
||||
Побочно выяснилось расхождение в постановке: я просил проверить форму `#*`, но такая строка регуляркой заголовка вообще не матчится — после `#` обязателен пробел или конец строки. Заголовком является только `# *`, эта форма и покрыта тестом.
|
||||
|
||||
---
|
||||
|
||||
## 2. Горячий путь измерения текста ускорен в 4.4 раза
|
||||
|
||||
### Кэш объектов `Font`
|
||||
|
||||
Замер показал, что один `Font()` конструируется 2.62 мс: открывает FreeType `Face`, грузит `ImageFont.truetype` и аллоцирует PIL-канвас 1000×1000. При этом он создавался дважды за вызов `calculate_height` плюс по разу на каждый run.
|
||||
|
||||
Добавлен модульный `_get_font(name, bold, italic, size_pt)` под `lru_cache(maxsize=256)`, все три места конструирования переведены на него. Сам класс `Font` оставлен публичным — существующие тесты создают его напрямую.
|
||||
|
||||
### Скрытая мина в `is_mono`
|
||||
|
||||
Попутно нашлось то, чего в плане не было. Ветка `get_text_width` для моноширинных шрифтов читала `self._face.glyph.advance.x` — то есть тот глиф, который последним оставил в слоте `load_char`. Всегда оказывался `"m"`, потому что единственные вызовы `load_char` живут в `is_mono`. Значение было верным, но верным по случайности: любое постороннее обращение к face молча его сломало бы.
|
||||
|
||||
`is_mono` заменён на свойство `_metrics`, которое снимает и флаг моноширинности, и advance за один проход. Результат теперь не зависит от порядка обращений к слоту глифа. Есть тест, который долбит `load_char` посторонними символами и проверяет, что ширина не поехала.
|
||||
|
||||
### Квадратичный пересчёт ширины слова
|
||||
|
||||
`count_lines` пересчитывал ширину префикса слова после каждого символа, хотя результат читается только в ветке `if c == " "`. Вызов перенесён в две точки: на пробеле и один раз в конце каждого run.
|
||||
|
||||
Вторая точка — та, которую легко пропустить: слово может пересекать границу run'ов, а следующая итерация добавляет свежий слот в `word_parts_widths`, поэтому фрагмент нужно померить до этого.
|
||||
|
||||
Намеренно сохранено одно неочевидное поведение оригинала: `word_part` не сбрасывается после замера на пробеле, потому что исходный код сбрасывал его только внутри ветки `if any(word_parts_widths)`, и фрагмент нулевой ширины продолжал накапливаться через пробел.
|
||||
|
||||
### Синглтон XSLT
|
||||
|
||||
`md2gost/latex_math.py` парсил `mml2omml` с диска и компилировал `etree.XSLT` заново на каждую формулу. Вынесено в ленивый `_mml2omml_transform()`. Оставлено внутри `try`, чтобы битый или отсутствующий stylesheet по-прежнему давал тот же `ValueError` при каждом вызове.
|
||||
|
||||
### Как доказана эквивалентность
|
||||
|
||||
Не чтением кода. Пять эталонных тестовых файлов прогнаны до и после, verbose-вывод сдиффен построчно — идентичен. Плюс `tests/test_paragraph_sizer_cache.py` содержит дословные копии дооптимизационных `Font` и `count_lines` и сверяет с ними новый код на 158 случаях: одиночные и многорановые абзацы, разрыв слова на границе run'а, пустые run'ы, `w:noBreakHyphen`, смешанные полужирный с курсивом, кириллица, длинные неразрывные слова, сдвоенные и хвостовые пробелы, три ширины колонки, оба значения `is_mono`.
|
||||
|
||||
Чтобы убедиться, что корпус не вырожденный, замер в конце run'а был временно отключён — тесты упали в 5 местах.
|
||||
|
||||
---
|
||||
|
||||
## 3. Диаграммы: одна JVM на документ вместо одной на диаграмму
|
||||
|
||||
Перед проектированием было проверено фактическое поведение jar: PlantUML принимает много входных файлов за запуск, но только один флаг `-t<fmt>` (передача `-tpng -tsvg` молча отдаёт только SVG), а относительный `-o` резолвится от каталога каждого входного файла.
|
||||
|
||||
Отсюда конструкция: каждый источник получает свой подкаталог `NNNN/src.puml` с `-o out`. Это заодно решает коллизию двух источников, объявляющих `@startuml SameName`.
|
||||
|
||||
`DiagramFigure.__init__` регистрирует свой блок через `register_pending_diagram()`. Поскольку `Converter.convert()` полностью материализует `list(self.parser.parse())` до рендера, к моменту первого вызова `render_diagram()` очередь содержит весь документ. Первый вызов запускает одноразовый предпроход: готовит все источники, отбрасывает попавшие в кэш, остальные рендерит одной JVM.
|
||||
|
||||
Всё, что упало в предпроходе, остаётся промахом кэша и идёт обычным путём, так что цепочка PlantUML jar → локальный Kroki → удалённый Kroki и режимы `--diagram-fallback` не тронуты. `_render_plantuml_jar_one` теперь обёртка на одну задачу поверх того же батч-раннера, чтобы одиночный и пакетный пути не разъехались.
|
||||
|
||||
### Найденный попутно баг
|
||||
|
||||
Старый код писал `diagram.puml` и искал `diagram.{fmt}`. Любой источник с `@startuml SomeName` производил `SomeName.png`, считался провалом jar и молча уезжал на Kroki. Новая логика забирает то, что jar реально положил в выходной каталог этой диаграммы, и рендерит локально.
|
||||
|
||||
### Остальное по диаграммам
|
||||
|
||||
- Версия jar добавлена в ключ кэша: `PLANTUML_CACHE_PREFIX = f"plantuml-{PLANTUML_VERSION}\n"`. Раньше для plantuml префикс был пустой, и обновление jar отдавало старые PNG.
|
||||
- `ensure_user_schemes()` убран из `render_diagram` — он мигрировал `md2gost.schemes.json` на каждую диаграмму. Безопасно: `load_merged_schemes()` его и так вызывает.
|
||||
- Добавлен `shutdown_diagrams()`, закрывающий сессию Playwright, вызывается из существующего `finally` в `pipeline.convert()`.
|
||||
- Graphviz для DFD теперь отдаёт PNG и SVG из одного процесса `dot`. Проверяет наличие и непустоту каждого выхода и повторяет недостающий формат старым путём, если сборка Graphviz не поддерживает пары.
|
||||
|
||||
Что проверено вживую: путь через jar целиком, включая батч, попадания в кэш, источники с `@startuml Name`, полный прогон `pipeline.convert()`. Graphviz тоже оказался установлен — комбинированный запуск реально произвёл оба файла, и на этом всплыло, что существующий DFD-тест тянулся мимо своего мока.
|
||||
|
||||
Что не проверено: локальный и удалённый Kroki (сервера нет, сетевых вызовов не делалось) — только моки; закрытие браузера в реальной mermaid-конвертации, потому что здешние тесты идут через QuickJS.
|
||||
|
||||
---
|
||||
|
||||
## 4. Двойная работа при рендере убрана
|
||||
|
||||
Все три места сначала обвешаны замерами, потом правлены. Доказательство для каждого — матрица из 4 документов на 7 наборов опций, 28 конвертаций, и побайтовое сравнение `word/document.xml` со снимком, снятым до любых правок. **28 из 28 идентичны.** В матрице `examples/example.md`, `examples/pis_custom.md`, синтетический стресс-документ (многостраничная дробящаяся таблица со слитыми ячейками, многостраничный листинг, три фигуры) и документ с альбомной ориентацией, каждый в режимах продолжений off / legacy / caption / soft, плюс ручное содержание, тип `PIS_custom` и авто-нумерация заголовков.
|
||||
|
||||
### Повторный рендер на границе страницы
|
||||
|
||||
`md2gost/renderer.py`. Раньше: отрендерить блок, померить первый `RenderedInfo`, и если не влезает — выбросить генератор и вызвать `render()` заново. Для таблицы или листинга это пересборка всего поддерева DOCX и повтор всех замеров.
|
||||
|
||||
Решение оказалось точнее, чем «разделить measure и emit», и это выяснилось именно замером. Повтор нужен только когда в `self._to_new_page` реально что-то стоит в очереди: отложенные фигуры вставляются первыми и двигают границу страницы, поэтому блок приходится раскладывать заново уже против оставленного ими состояния. Если очередь пуста, `_flush_to_new_screen()` — no-op, и повторный рендер строит то же поддерево против того же состояния.
|
||||
|
||||
Инструментирование показало, что **во всех срабатываниях на всей матрице очередь была пуста**, то есть второй проход был чистой потерей всегда. Триггером почти везде оказывались заголовки с `page_break_before` и одна фигура — ни разу таблица или листинг.
|
||||
|
||||
Условие дополнено проверкой `and self._to_new_page`. Структура render → measure → flush → re-render сохранена ровно для того случая, когда flush действительно меняет состояние.
|
||||
|
||||
### Подпись рисунка мерилась дважды
|
||||
|
||||
`md2gost/renderable/image.py`. Предложенный в плане `Image.estimate_height()` **не понадобился**: замер показал, что геометрия считается дёшево, а реальная стоимость сухого прохода — построение свежего `Caption` и прогон сайзера по нему. Поэтому подпись создаётся один раз и переиспользуется (сбрасывается в `set_number()`), а сама структура «сухой проход плюс настоящий рендер» не тронута. Именно поэтому вывод и смог остаться идентичным.
|
||||
|
||||
### Кэш замеров абзаца
|
||||
|
||||
Строки таблицы мерились до трёх раз. Теперь `_peek_row_height` и `_build_docx_row` идут через один мемоизированный замер, а выбрасываемый сухой `Caption` в `_start_block_height` убран — `render()` строит подпись один раз и передаёт её внутрь.
|
||||
|
||||
Общий хелпер `measure_paragraph()` в `paragraph_sizer.py` мемоизирует `calculate_height()` в разрезе владеющего renderable. Ключ собран так, чтобы это было безопасно в коде, решающем разрывы страниц:
|
||||
|
||||
- сериализованный XML измеряемого абзаца, а **не** идентичность объекта — правка между двумя вызовами тогда даёт промах кэша вместо устаревшей высоты;
|
||||
- сериализованный `w:pPr` предшественника, а не он целиком — сайзер читает только `pPr`, а привязка `w:p` к телу документа заставляет lxml поднять на него объявления пространств имён, из-за чего полная сериализация выглядела бы как правка и молча промахивалась;
|
||||
- ширина.
|
||||
|
||||
Сверх побайтового сравнения был прогнан «паранойяльный» режим, пересчитывающий значение на каждом попадании в кэш и сверяющий с сохранённым. Вся матрица прошла без единого устаревшего попадания. Строительные леса убраны, а `tests/test_paragraph_sizer_cache.py` получил семь регрессионных тестов: попадание равно свежему расчёту, инвалидация при правке текста и при правке интервалов, ширина и интервалы предшественника как части ключа, контракт приватной копии (вызывающий обнуляет `.before` на новой странице), случай с привязкой предшественника.
|
||||
|
||||
Попутно `page_break_before = False` перенесён из конца `render()` в `__init__`, чтобы оба замера подписи видели один и тот же абзац.
|
||||
|
||||
**Сквозное время конвертации упало на 10-23%** в зависимости от документа.
|
||||
|
||||
---
|
||||
|
||||
## 5. Удалено 6777 строк мёртвого кода
|
||||
|
||||
Больше моей исходной оценки в 5646. Для каждого файла искались импорты модуля, все top-level символы (извлечены через `ast`) и имя файла в `*.spec`, `*.bat`, `*.toml`, `.github/workflows/`, `tests/` и всех живых пакетах.
|
||||
|
||||
| Файл | Строк | Чем был |
|
||||
|---|---|---|
|
||||
| `mm3.py` | 2210 | Монолит + matplotlib-графики |
|
||||
| `mm2.py` | 1551 | Монолитный сборщик DOCX, версия 2 |
|
||||
| `main12.py` | 915 | Ранний `MarkdownGostDocument` |
|
||||
| `font.py` | 860 | Своя реализация Font API |
|
||||
| `parser.py` | 340 | Песочница на mistune |
|
||||
| `asd.py` | 317 | Эксперимент со стилями ГОСТ |
|
||||
| `mm5.py` | 284 | Прототип на mistune |
|
||||
| `mm4.py` | 241 | Построчный прототип |
|
||||
| `main.py` | 32 | Hello-world на python-docx |
|
||||
| `_audit_tmp.py` | 27 | Разовый инспектор DOCX |
|
||||
|
||||
Проверены две ловушки: корневой `parser.py` мёртв, а `md2gost/parser_.py` живой и импортируется в `converter.py`; корневой `font.py` мёртв, а `md2gost/renderable/find_font.py` живой. Единственное вхождение `font` в импортах живых пакетов — `from docx.text.font import Font as DocxFont`, то есть сторонняя библиотека.
|
||||
|
||||
Мёртвый код внутри пакета: убраны `APPENDIX_RE` в `renderer.py` (дублировал константу из `label_pass.py`), `Listing._create_table`, `Converter._output_path`, no-op ветка `isinstance(r, Paragraph): pass` в `label_pass.py`, `_BrowserSession.render_svg()`, `_draw_purpose()` в `idef0.py`, плюс осиротевший `import os` в `paragraph_sizer.py`.
|
||||
|
||||
Две поправки к моему списку. **`gui.main()` оказался живым** — это консольный скрипт `md2gost-gui` в `pyproject.toml`, я ошибочно записал его в мёртвые; оставлен как есть. И комментарий у `Listing._create_table`, утверждавший, что метод держат ради тестов, не подтвердился: тесты ссылаются на модульную функцию `create_table`, а не на метод.
|
||||
|
||||
У `Converter._output_path` заодно убран и параметр конструктора — оставлять параметр, который класс игнорирует, смысла нет. `pipeline.py` был единственным вызывающим, его вызов переведён на именованные аргументы, чтобы остальные позиционные не съехали.
|
||||
|
||||
---
|
||||
|
||||
## 6. Гигиена репозитория
|
||||
|
||||
Снято с отслеживания, файлы остались на диске: `ReadMe.docx`, `md2gost/README.docx`, `tmp_*.docx`, `README.md1`, `examples/example_out.docx`, `examples/toc_*.docx`, `tests/_idef0_preview*`, `latex/mirea/Settings/FiraCode-Regular.otf` (220 КБ, ни одной ссылки в `.tex`), каталог `ad/`, `md2gost.user.json`.
|
||||
|
||||
Перенесено с сохранением истории: `_method_extract.txt` → `docs/reference/method-mirea-2022.txt`, `tests/extended_mardown/` → `tests/extended_markdown/`.
|
||||
|
||||
`md2gost.user.json` содержал реальные ФИО и группу. Убран из индекса, файл на диске остался рабочим, рядом появился `md2gost.user.json.example` с пустыми полями — структура сгенерирована из `UserProfile()` и `DocumentMetadata()`, так что совпадает с тем, что пишет `save_user_profile`.
|
||||
|
||||
### Зависимости
|
||||
|
||||
Легаси-секции `[tool.poetry]`, `poetry.lock` и `requirements.txt` удалены, канон — `[project]` плюс `uv.lock`.
|
||||
|
||||
При этом выяснилось, что `matplotlib` был объявлен **только** в удаляемой Poetry-секции, хотя импортируется в рантайме из `find_font.py` на всех платформах кроме Linux. Добавлено `matplotlib>=3.7; sys_platform != 'linux'` в `[project].dependencies`. Также поправлен `pywin32` — он был добавлен без маркера платформы, что ломало бы `pip install .` на Linux-CI, теперь `pywin32; sys_platform == 'win32'`.
|
||||
|
||||
Поле `readme` исправлено с `README.md` на фактическое `ReadMe.md` — расхождение регистра сломало бы сборку колеса на Linux.
|
||||
|
||||
### CI
|
||||
|
||||
`example-generator.yml` не запускался ни разу: триггер стоял на ветку `main`, а репозиторий на `master`. Исправлено в обоих триггерах. Артефакт грузился из `example.docx`, тогда как `default_output_path` кладёт файл рядом с входным — путь исправлен на `examples/example.docx`. Имя воркфлоу совпадало с `pytest.yml`, переименовано в `Example document`. Обновлены `upload-artifact@v3` → `v4`, `checkout@v3` → `v4`, `setup-python@v3` → `v5`, добавлен `apt-get update` перед установкой шрифтов.
|
||||
|
||||
### Тесты
|
||||
|
||||
Два метода с именем `test_count_lines_courier7` затирали друг друга — второй переименован, расшадоуенный первый **проходит**, латентного бага не вскрылось. Голые `return` в двух win32-тестах `test_pipeline.py` заменены на `@pytest.mark.skipif` (раньше они проходили без единой проверки). Опечатка в имени каталога `extended_mardown` исправлена.
|
||||
|
||||
Кстати про нумерацию: `test_count_lines_courier7` — это индекс 7 в серии от `courier1` до `courier10`, а не размер 7 пт. Третьего размера шрифта не существует, есть только 10 в `style_config` и 12 в фикстуре и калибровке.
|
||||
|
||||
---
|
||||
|
||||
## 7. Дедупликация сиблинг-конвертеров
|
||||
|
||||
`md2latex/emitter.py` и `md2fodt/emitter.py` содержали примерно 150-200 строк почти идентичного обхода AST: диспетчер блоков, инлайн-обходчик, детектор строк библиографии, машина состояний `pending_caption`, словарь русских подписей ссылок.
|
||||
|
||||
Создан отдельный верхнеуровневый пакет `mdexport` — внутрь `md2gost` его класть было нельзя, а размещение в одном из экспортёров сделало бы один бэкенд зависимым от другого. Три модуля:
|
||||
|
||||
- `blocks.py` — `walk_blocks`, `walk_paragraph`, ABC `BlockVisitor`, кортеж отложенной подписи и `take_caption()`, детектор строк библиографии, парсинг заголовка картинки `%id Подпись`, `is_contents_heading`
|
||||
- `inline.py` — `render_inline`, ABC `InlineRenderer`, таблица русских названий ссылок
|
||||
- `labels.py` — `sanitize_label`, параметризованный разделителем: `:` для LaTeX, `-` для XML `NCName`
|
||||
|
||||
`escape_text` остался в бэкендах — там разные задачи, карта символов LaTeX против `saxutils` для XML.
|
||||
|
||||
Эмиттеры сократились на 119 строк, сам общий пакет занял 309, из которых около 120 — новый интерфейсный каркас: объявления абстрактных методов, докстринги, реэкспорты. По суммарному LOC выигрыш скромный, реальный смысл в том, что перечень типов узлов и порядок элементов теперь существуют в одном месте. Раньше новый тип узла приходилось добавлять в две цепочки `if/elif`, которые уже разъехались: в `md2latex` жил мёртвый словарь `cat`, которого на стороне FODT не было вовсе.
|
||||
|
||||
Побайтовая сверка: шесть снимков (`example.tex`, `example.fodt`, `example_emdash.fodt`, `pis_custom.tex`, `pis_custom.fodt`, `pis_custom_emdash.fodt`) совпали по SHA-256. Но образцы не дотягиваются до курсива, полужирного, кодовых вставок, ссылок, разрывов строк, цитат, вложенных списков, отсутствующих файлов картинок и экранирования специальных символов LaTeX — поэтому дооптимизационные эмиттеры были дополнительно выкачены из `HEAD` в temp и прогнаны на стресс-документе, покрывающем эти пути. `stress.tex`, `stress.fodt`, `stress_emdash.fodt` тоже идентичны.
|
||||
|
||||
`word2md/walker.py`: функция `_handle_paragraph` на 207 строк превращена в тело из 27 строк — собирается неизменяемая запись `_Para` с четырьмя фактами, нужными всем ветвям, затем прогоняется кортеж из одиннадцати обработчиков до первого, вернувшего не `None`. Возврат `None` означает «не мой случай, идём дальше», что сохранило два исходных сквозных пути: подпись со стилем подписи, чей текст не парсится, и строку со стилем библиографии без маркера `[n]`.
|
||||
|
||||
Тем же способом проверено и здесь: восемь конвертаций (синтетический DOCX с титульной частью, стилем и полем содержания, подписями продолжений, неразбираемой подписью, кодовыми абзацами, списками через `numPr` и через отступ, разрывами страниц, строкой библиографии без маркера, плюс полный круг `example.md` → DOCX → Markdown, каждая с переключением `keep_toc_pages` и `emit_page_breaks`) дают побайтово идентичный Markdown против дорефакторного обходчика.
|
||||
|
||||
Попутно убраны три мёртвых фрагмента: повторный вызов чистой функции при `parsed is None and kind`, no-op `if kind and parsed.kind != kind: pass` (из-за которого весь поиск `caption_kind_from_style` оказывался мёртвым, вместе с импортом), и `try/except` вокруг `re.match` по значению, которое всегда строка.
|
||||
|
||||
**Два следствия, требующие внимания.** `mdexport/` остался untracked, при этом уже прописан в `pyproject.toml` и импортируется обоими конвертерами — это пункт P0 в [TODO.md](TODO.md). И при разборе обнаружился реальный баг потери данных в обработчиках списков `word2md`, существовавший и до правок: он сохранён намеренно ради поведенческой нейтральности и описан в [TODO.md](TODO.md) с механизмом и порядком починки.
|
||||
|
||||
Спецификации PyInstaller править не пришлось — они идут по статическим импортам.
|
||||
|
||||
---
|
||||
|
||||
## Как это делалось
|
||||
|
||||
Работа шла шестью параллельными исполнителями, разведёнными по владению файлами, чтобы не наступать друг другу на правки. Волна 1: фиксы багов, кэш горячего пути, диаграммы, чистка репозитория. Волна 2: двойной рендер вместе с мёртвым кодом внутри пакета, поскольку они делят `renderer.py`. Отдельным потоком шли сиблинг-конвертеры — единственная область, не пересекавшаяся ни с чем.
|
||||
|
||||
Три требования, заданных жёстко всем исполнителям: правки, меняющие раскладку страницы, доказываются побайтовым сравнением вывода, а не чтением кода; вопросы Фазы 1a не трогаются без решения владельца; коммиты и деструктивные git-команды запрещены.
|
||||
@@ -0,0 +1,84 @@
|
||||
# CLI и GUI
|
||||
|
||||
Движок один: GUI собирает те же параметры, что и аргументы `python -m md2gost`.
|
||||
|
||||
## Основные команды
|
||||
|
||||
```bash
|
||||
python -m md2gost # GUI
|
||||
python -m md2gost --gui report.md
|
||||
python -m md2gost report.md -o out.docx --type coursework --check --strict
|
||||
python -m md2gost report.docx -o report.md # импорт Word → Markdown (word2md)
|
||||
python -m md2gost report.docx --check-pages # только проверка вёрстки готового DOCX
|
||||
python -m word2md report.docx -o report.md
|
||||
```
|
||||
|
||||
Справка: `python -m md2gost -h`.
|
||||
|
||||
## Соответствие флагов и GUI
|
||||
|
||||
| CLI | Где в GUI | Смысл |
|
||||
|-----|-----------|--------|
|
||||
| `--type` | Основные → тип | practice, coursework, vkr, PIS_custom, APID_coursework |
|
||||
| `-o` / `--output` | Выходной DOCX | путь результата |
|
||||
| `--heading-numbering` | Основные | `manual` (цифры в md) / `auto` (Word) |
|
||||
| `--toc` | Основные | `native` (поле Word) / `manual` (собирает md2gost) |
|
||||
| `--emdash-to-hyphen` | Галочка «—» → «-» | по умолчанию выкл. |
|
||||
| `--hr-pagebreak` | Галочка --- → разрыв | по умолчанию `---` игнорируется |
|
||||
| `--title` / `--assignment` / `-t` | Настройки → Файлы | титул, задание, шаблон |
|
||||
| `--auto-title` / `--no-auto-title` | галочка «Автотитул» | автогенерация титула (по умолчанию выкл.) |
|
||||
| `--student` / `--group` | Настройки → Студент… | ФИО и группа для автотитула |
|
||||
| `--doc-author-from` / `--doc-author` | Настройки → Метаданные… | автор в свойствах DOCX (`os` / `student` / `custom`) |
|
||||
| `--doc-title` / `--doc-subject` / `--doc-keywords` / `--doc-comments` / `--doc-category` / `--doc-last-modified-by` | Настройки → Метаданные… | остальные поля File → сведения |
|
||||
| `--check` / `--check-only` / `--strict` | Галочки проверки ТЗ | замечания по структуре и источникам |
|
||||
| `--check-pages` | Проверить вёрстку в Word | эвристика полупустых страниц (Word + pywin32) |
|
||||
| `--table-continuation` | Основные | `word` (по умолчанию), `off`, `legacy`, `caption`, `soft` |
|
||||
| `--listing-continuation` | Основные | то же для листингов |
|
||||
| `--table-repeat-header` | Галочка | шапка на фрагментах после word-split |
|
||||
| `--plantuml-jar` / `--kroki-url` | Настройки → Диаграммы | локальный рендер UML / запасной Kroki |
|
||||
| `--diagram-fallback` | Диаграммы | `remote` / `local` / `off` (отключает только kroki.io) |
|
||||
| `--diagram-format` | Диаграммы | `png` / `svg` |
|
||||
| `--diagram-scale` | Диаграммы | качество PNG PlantUML / локального Mermaid (по умолчанию 2) |
|
||||
| `--page-start N` | Основные → Смещение страниц | начальный номер PAGE в теле; пусто = сквозной |
|
||||
| (нет CLI) | Диаграммы → «Скачать Graphviz» | portable Graphviz для DFD (`vendor` или `%LOCALAPPDATA%\md2gost\graphviz`) |
|
||||
| `--install-chromium` | Диаграммы → «Скачать headless Chromium» | Chromium для Mermaid в `%LOCALAPPDATA%\md2gost` |
|
||||
| `--schemes` | файл схем / Шаблоны UML | путь к `md2gost.schemes.json` |
|
||||
| `--styles` | Настройки → Файлы → Стили JSON | оверлей `md2gost.styles.json` (также рядом с `.md`); см. [styles.md](styles.md) |
|
||||
| `--syntax-highlighting` | (CLI) | подсветка в листингах |
|
||||
| `--debug` | меню Дебаг | отладочные данные в документе (на одну сборку в GUI) |
|
||||
|
||||
## Продолжение таблиц и листингов
|
||||
|
||||
DOCX не умеет сам писать «Продолжение Таблицы N» только со 2-й страницы. Режим **`word`** (по умолчанию): после сохранения Word COM режет по реальной пагинации. Нужны Windows + Word + pywin32. Без Word: `--table-continuation off` (и то же для листингов).
|
||||
|
||||
## Меню GUI
|
||||
|
||||
- **Настройки → Файлы** — шаблон, титул, задание, стили JSON, выходной путь
|
||||
- **Настройки → Студент…** — ФИО и группа (при первом запуске спрашивают сами)
|
||||
- **Настройки → Метаданные…** — свойства DOCX (автор, название, тема, теги, примечание)
|
||||
- **Настройки → Диаграммы** — PlantUML, Graphviz (DFD), Mermaid (Chromium), Kroki, формат, кэш includes
|
||||
- **Шаблоны UML** — редактор `md2gost.schemes.json`
|
||||
- **Справка → Инструкция / Документация / Схемы / Промпт для ИИ**
|
||||
(Документация — встроенный просмотр `docs/*.md`, вшито в exe)
|
||||
|
||||
## Автотитульник
|
||||
|
||||
Кратко: **по умолчанию выкл.** Вкл. — `--auto-title` или галочка «Автотитул». Рядом с `.md` — `info_conv.yaml`, в MD — опциональный блок `title` (`number` / `year`), ФИО и группа — **Настройки → Студент…** или `--student` / `--group`. Явный `--title` отключает генератор.
|
||||
|
||||
Подробно (примеры, таблица полей, приоритеты, FAQ): **[title.md](title.md)**.
|
||||
|
||||
## Свойства файла Word
|
||||
|
||||
По умолчанию в DOCX пишутся **автор = имя пользователя Windows** и примечание «Создано при помощи md2gost (ТЗ МИРЭА)». Даты создания/изменения ставятся на момент сборки, revision = 1 (чтобы не утекали 2013 год и revision из шаблона).
|
||||
|
||||
Задать свои поля: **Настройки → Метаданные…** (сохраняется в `md2gost.user.json`) или флаги `--doc-author-from` / `--doc-author` / `--doc-title` / `--doc-subject` / `--doc-keywords` / `--doc-comments` / `--doc-category` / `--doc-last-modified-by`. Источник автора: `os` (по умолчанию), `student` (ФИО из профиля), `custom` (строка). Пустое `--doc-comments` / пустое примечание в GUI — не писать штамп md2gost.
|
||||
|
||||
## Другие форматы
|
||||
|
||||
```bash
|
||||
python -m md2fodt report.md -o report.fodt # LibreOffice
|
||||
python -m word2md report.docx -o report.md # Word → Markdown (диалект md2gost)
|
||||
# PDF через LaTeX — см. md2latex/
|
||||
```
|
||||
|
||||
Импорт DOCX подробнее: [word2md.md](word2md.md). В GUI — кнопка «Импорт DOCX→MD» или drop `.docx`.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Ручная чистка git
|
||||
|
||||
Команды для удаления мусора из репозитория. Всё под PowerShell — помни, что `&&` там не работает как разделитель, используй `;` или отдельные вызовы.
|
||||
|
||||
Файл описывает то, что осталось сделать руками. То, что уже сделано агентами и лежит в staging, перечислено в конце для сверки.
|
||||
|
||||
---
|
||||
|
||||
## 0. Подготовка
|
||||
|
||||
Чтобы git печатал кириллические имена читаемо, а не как `\320\242...`:
|
||||
|
||||
```powershell
|
||||
git config core.quotepath false
|
||||
```
|
||||
|
||||
Снимок текущего состояния — на случай, если захочется откатиться:
|
||||
|
||||
```powershell
|
||||
git status --short > ..\git-status-before.txt
|
||||
git stash list
|
||||
```
|
||||
|
||||
Ничего из перечисленного ниже не переписывает историю и не трогает remote, так что откат всегда возможен через `git restore --staged <path>`.
|
||||
|
||||
---
|
||||
|
||||
## 1. `Other_Code/` — 59 файлов, 1158 КБ
|
||||
|
||||
Что это: схемы OOXML (`*.xsd`), скрипты pack/unpack/validate, `document.py`, `utilities.py`, `docx-js.md`, `SKILL.md`. Справочный материал.
|
||||
|
||||
Почему можно убирать: ни одного импорта из `md2gost`, `word2md`, `md2latex`, `md2fodt`, `tests/`, ни упоминания в `.spec`, `.bat`, `.github/workflows/`, `pyproject.toml`. Единственные ссылки — раздел 12 в `PROJECT.md` и внутренние перекрёстные ссылки самого каталога.
|
||||
|
||||
Проверь сам, прежде чем удалять:
|
||||
|
||||
```powershell
|
||||
git grep -n "Other_Code" -- ':!Other_Code' ':!PROJECT.md'
|
||||
git grep -nE "^\s*(import|from)\s+(scripts|validation)\b" -- 'md2gost' 'word2md' 'md2latex' 'md2fodt' 'tests'
|
||||
```
|
||||
|
||||
Ожидаемый результат обеих команд — пусто.
|
||||
|
||||
### Вариант А: снять с отслеживания, файлы оставить на диске
|
||||
|
||||
Подходит, если хочешь держать схемы под рукой локально.
|
||||
|
||||
```powershell
|
||||
git rm -r --cached Other_Code
|
||||
Add-Content -Path .gitignore -Value "`n# Справочные схемы OOXML (не часть продукта)`n/Other_Code/"
|
||||
```
|
||||
|
||||
### Вариант Б: удалить полностью
|
||||
|
||||
```powershell
|
||||
git rm -r Other_Code
|
||||
```
|
||||
|
||||
### Вариант В: вынести в отдельную ветку и потом удалить
|
||||
|
||||
Если материал ценен, но не должен висеть в основной ветке:
|
||||
|
||||
```powershell
|
||||
git subtree split --prefix=Other_Code -b reference/ooxml-schemas
|
||||
git rm -r Other_Code
|
||||
```
|
||||
|
||||
Ветка `reference/ooxml-schemas` останется локально с полной историей каталога.
|
||||
|
||||
После любого варианта подчисти упоминание в `PROJECT.md`, раздел 12.
|
||||
|
||||
---
|
||||
|
||||
## 2. Личные документы в корне
|
||||
|
||||
```
|
||||
Практическая_работа_6.docx 42 КБ
|
||||
ТарасовИА_КР_v3.docx 2326 КБ
|
||||
```
|
||||
|
||||
Та же категория, что и `md2gost.user.json`: персональные данные, причём ФИО прямо в имени файла. Ни одной ссылки из кода, тестов или сборочных скриптов.
|
||||
|
||||
Снять с отслеживания, файлы оставить на диске:
|
||||
|
||||
```powershell
|
||||
git rm --cached "Практическая_работа_6.docx"
|
||||
git rm --cached "ТарасовИА_КР_v3.docx"
|
||||
```
|
||||
|
||||
Если кириллица в аргументах ломается, обойди её через pathspec по расширению и отбери нужное вручную:
|
||||
|
||||
```powershell
|
||||
git ls-files "*.docx"
|
||||
```
|
||||
|
||||
Правило в `.gitignore`, чтобы не вернулись:
|
||||
|
||||
```powershell
|
||||
Add-Content -Path .gitignore -Value "`n# Личные документы (ФИО в имени файла)`n/*.docx`n!md2gost/Template.docx`n!md2gost/TitleTemplate.docx"
|
||||
```
|
||||
|
||||
**Осторожно с этим правилом.** `md2gost/Template.docx` и `md2gost/TitleTemplate.docx` — живые файлы, они force-include в колесо через `pyproject.toml` и попадают в exe через `.spec`. Правило выше их исключает, но проверь результат:
|
||||
|
||||
```powershell
|
||||
git check-ignore -v md2gost/Template.docx md2gost/TitleTemplate.docx
|
||||
```
|
||||
|
||||
Обе строки должны вернуть пусто (то есть файлы НЕ игнорируются). Если вернули правило — убери `/*.docx` и перечисли два личных файла поимённо.
|
||||
|
||||
---
|
||||
|
||||
## 3. Проверка результата
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git ls-files | Measure-Object | Select-Object -ExpandProperty Count
|
||||
```
|
||||
|
||||
Размер отслеживаемого дерева до и после:
|
||||
|
||||
```powershell
|
||||
(git ls-files | ForEach-Object { (Get-Item -LiteralPath $_).Length } | Measure-Object -Sum).Sum/1MB
|
||||
```
|
||||
|
||||
Убедись, что ничего живого не выпало:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -c "import md2gost, word2md, md2latex, md2fodt; print('ok')"
|
||||
$env:PYTHONIOENCODING="utf-8"; .venv\Scripts\python.exe -m md2gost --help
|
||||
.venv\Scripts\python.exe -m pytest -q
|
||||
```
|
||||
|
||||
Переменная `PYTHONIOENCODING` нужна из-за cp1251 в консоли — без неё `--help` падает на символе `→`, хотя код в порядке.
|
||||
|
||||
---
|
||||
|
||||
## 4. Про историю: снятие с отслеживания её не чистит
|
||||
|
||||
Это важно понимать. `git rm --cached` убирает файл из индекса и будущих коммитов, но все прошлые коммиты остаются нетронутыми. То есть:
|
||||
|
||||
- `md2gost.user.json` с твоими ФИО и группой **по-прежнему лежит в истории**
|
||||
- `ТарасовИА_КР_v3.docx` тоже, все 2.3 МБ, в каждом коммите, где он менялся
|
||||
- удалённые прототипы `mm2.py`/`mm3.py` и `Other_Code/` продолжают занимать место в `.git`
|
||||
|
||||
Если репозиторий приватный и таким останется — этого достаточно, дальше можно не читать.
|
||||
|
||||
Если планируешь публиковать, историю надо переписывать. Инструмент — `git filter-repo` (BFG устарел):
|
||||
|
||||
```powershell
|
||||
uvx git-filter-repo --path md2gost.user.json --invert-paths
|
||||
uvx git-filter-repo --path "ТарасовИА_КР_v3.docx" --invert-paths
|
||||
uvx git-filter-repo --path Other_Code --invert-paths
|
||||
```
|
||||
|
||||
Перед этим обязательно:
|
||||
|
||||
1. Сделай полную копию каталога `.git`, а лучше всего репозитория — операция необратима.
|
||||
2. Учти, что все хеши коммитов сменятся. Если репозиторий уже где-то запушен, всем придётся клонировать заново.
|
||||
3. Прогони `git reflog expire --expire=now --all; git gc --prune=now --aggressive`, иначе объекты останутся в паке.
|
||||
|
||||
Я специально не делал это автоматически: перезапись истории — необратимая операция, её стоит запускать осознанно и на копии.
|
||||
|
||||
---
|
||||
|
||||
## Приложение: что уже сделано агентами
|
||||
|
||||
Всё перечисленное лежит в staging, **не закоммичено**. Просмотри `git diff --cached` перед коммитом.
|
||||
|
||||
Удалено с диска (мёртвые прототипы, 6777 строк):
|
||||
|
||||
```
|
||||
mm2.py mm3.py mm4.py mm5.py main12.py main.py
|
||||
asd.py parser.py font.py _audit_tmp.py
|
||||
poetry.lock requirements.txt
|
||||
```
|
||||
|
||||
Снято с отслеживания, файлы остались на диске:
|
||||
|
||||
```
|
||||
ReadMe.docx md2gost/README.docx
|
||||
tmp_listing_word.md/.docx tmp_readme_word.docx
|
||||
tmp_readme_word2.docx README.md1
|
||||
examples/example_out.docx examples/toc_manual.docx
|
||||
examples/toc_native.docx tests/_idef0_preview*.png/.svg
|
||||
latex/mirea/Settings/FiraCode-Regular.otf
|
||||
ad/ (4 файла) md2gost.user.json
|
||||
```
|
||||
|
||||
Перенесено с сохранением истории:
|
||||
|
||||
```
|
||||
_method_extract.txt -> docs/reference/method-mirea-2022.txt
|
||||
tests/extended_mardown/ -> tests/extended_markdown/
|
||||
```
|
||||
|
||||
Добавлено: `md2gost.user.json.example` с пустыми полями.
|
||||
|
||||
### Побочный эффект правил игнора, о котором стоит знать
|
||||
|
||||
Новые правила `/tmp_*.png` и `/tmp_*.puml` захватили рабочие черновики `tmp_org_full.*`, `tmp_org_uml.*`, `tmp_arch.*` — они пропали из `git status`, хотя на диске целы. Если какой-то из них нужен под версионным контролем, добавь исключение:
|
||||
|
||||
```powershell
|
||||
Add-Content -Path .gitignore -Value "!/tmp_arch.puml"
|
||||
```
|
||||
|
||||
Каталог `ai/` не тронут и остаётся untracked.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Синтаксис Markdown (диалект md2gost)
|
||||
|
||||
Обычный Markdown описывает структуру текста. Для отчёта по ГОСТ нужны ещё: спецразделы без номера, подписи объектов, перекрёстные ссылки, библиография в заданном виде. **md2gost** расширяет MD ровно этими элементами; конвертер сам нумерует рисунки/таблицы/листинги и оформляет DOCX.
|
||||
|
||||
## Что совпадает с обычным MD
|
||||
|
||||
- Заголовки `#` … `######`
|
||||
- Абзацы, **жирный**, *курсив*
|
||||
- Маркированные и нумерованные списки
|
||||
- Таблицы `| … |`
|
||||
- Картинки ``
|
||||
- Блоки кода в ограде ` ```язык `
|
||||
- Формулы `$$ … $$` (и инлайн `$…$` где поддерживается)
|
||||
|
||||
## Зачем расширения
|
||||
|
||||
| Задача | Обычный MD | md2gost |
|
||||
|--------|------------|---------|
|
||||
| Введение без номера «1» | `# Введение` → станет разделом 1 | `# *ВВЕДЕНИЕ` |
|
||||
| Подпись «Рисунок 1.1 — …» | руками / HTML | `%id` или title у картинки |
|
||||
| «см. рис. 2» в тексте | нет семантики | `@Рисунок:id` |
|
||||
| UML → картинка в Word | экспорт PNG вручную | fence `` ```uml `` / схема → Рисунок |
|
||||
|
||||
## Спецразделы
|
||||
|
||||
Звёздочка `*` = без автоматической нумерации раздела. Текст — ПРОПИСНЫМИ:
|
||||
|
||||
```markdown
|
||||
# *СОДЕРЖАНИЕ
|
||||
[TOC]
|
||||
|
||||
# *ВВЕДЕНИЕ
|
||||
|
||||
# 1 Название первого раздела
|
||||
## 1.1 Подраздел
|
||||
|
||||
# *ЗАКЛЮЧЕНИЕ
|
||||
|
||||
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
|
||||
# *ПРИЛОЖЕНИЯ
|
||||
```
|
||||
|
||||
- После `# *СОДЕРЖАНИЕ` обязателен `[TOC]`.
|
||||
- В конце названия заголовка точку не ставить.
|
||||
- Для `PIS_custom` структура другая (практические работы) — см. [types.md](types.md).
|
||||
|
||||
## Рисунок (файл)
|
||||
|
||||
```markdown
|
||||
Текст со ссылкой на @Рисунок:arch.
|
||||
|
||||

|
||||
```
|
||||
|
||||
В тексте пишите **«Рисунок»**, не «рис.» / «рис».
|
||||
|
||||
## Таблица
|
||||
|
||||
```markdown
|
||||
См. @Таблица:cmp.
|
||||
|
||||
%cmp Название таблицы
|
||||
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
```
|
||||
|
||||
Склеивание ячеек:
|
||||
|
||||
- `^` — rowspan (продолжение ячейки сверху)
|
||||
- `>` — colspan (продолжение слева)
|
||||
|
||||
Не ставить `^`/`>` в заголовочной строке; `>` — не в первом столбце. Графу «№ п/п» не добавлять.
|
||||
|
||||
## Листинг
|
||||
|
||||
~~~markdown
|
||||
Фрагмент в @Листинг:code1.
|
||||
|
||||
%code1 Название листинга
|
||||
|
||||
```python
|
||||
def f():
|
||||
return 1
|
||||
```
|
||||
~~~
|
||||
|
||||
## Диаграмма → Рисунок
|
||||
|
||||
Перед блоком — `%id Подпись`. Языки: `uml`, `plantuml`, `mermaid`/`mmd`, `idef0`, `dfd`/`data-flow-diagram` (Graphviz вшитый/PATH или Kroki), или id схемы (`c4`, `usecase`, …).
|
||||
|
||||
- `+listing` — ещё и Листинг с исходником
|
||||
- `+landscape` — альбомная страница под широкий рисунок/таблицу
|
||||
|
||||
Подробности: [schemes.md](schemes.md).
|
||||
|
||||
## Формула
|
||||
|
||||
```markdown
|
||||
%eq1
|
||||
$$
|
||||
E = mc^2
|
||||
$$
|
||||
|
||||
Зависимость (@Формула:eq1) используется далее.
|
||||
```
|
||||
|
||||
Нумеруются **только** формулы, на которые есть `@Формула:…`.
|
||||
|
||||
## Ссылки на объекты
|
||||
|
||||
`@Рисунок:id`, `@Таблица:id`, `@Листинг:id`, `@Формула:id` — id совпадает с меткой после `%` или в title картинки.
|
||||
|
||||
## Источники
|
||||
|
||||
В тексте: `[1]`, `[2, 3]` (для ВКР — `[1.5]`). В DOCX номера становятся ссылками на пункты списка источников.
|
||||
|
||||
В списке:
|
||||
|
||||
```markdown
|
||||
[1]: Иванов И. И. Название. — М.: Наука, 2024. — 120 с.
|
||||
```
|
||||
|
||||
Во **ВВЕДЕНИИ** и **ЗАКЛЮЧЕНИИ** ссылок `[n]` быть не должно.
|
||||
|
||||
## Приложения
|
||||
|
||||
```markdown
|
||||
# *ПРИЛОЖЕНИЯ
|
||||
|
||||
## Приложение А Листинг модуля
|
||||
|
||||
…
|
||||
|
||||
## Приложение Б Графический материал
|
||||
```
|
||||
|
||||
Буквы: А, Б, В, Г, Д, Е, Ж, И, К… **Нельзя:** Ё, З, Й, О, Ч, Ь, Ы, Ъ.
|
||||
|
||||
## Тире и разрыв страницы
|
||||
|
||||
- Тире в предложениях: «—» (по умолчанию сохраняется). Замена на «-»: `--emdash-to-hyphen`.
|
||||
- Строка `---` / `***` / `___` по умолчанию **игнорируется**. Разрыв страницы: `--hr-pagebreak` или галочка в GUI.
|
||||
|
||||
## Автотитульник (метаданные в MD)
|
||||
|
||||
В начале файла можно указать блок с оградой `title` (номер работы, год, оверрайды полей). Рядом с файлом — `info_conv.yaml` с институтом, кафедрой, дисциплиной и преподавателем. ФИО и группа студента задаются в GUI / CLI, не в yaml. Автогенерация титула **по умолчанию выкл.** — включите `--auto-title` или галочку «Автотитул».
|
||||
|
||||
Подробно: [title.md](title.md).
|
||||
|
||||
## Запрещено в отчёте
|
||||
|
||||
- «рис.», «табл.»
|
||||
- графа «№ п/п»
|
||||
- сноски `[^1]`
|
||||
- формулы обычным текстом вместо `$$…$$`
|
||||
- нумерация спецразделов (`# ВВЕДЕНИЕ` вместо `# *ВВЕДЕНИЕ`)
|
||||
@@ -0,0 +1,34 @@
|
||||
# Промпты для ИИ
|
||||
|
||||
Промпты лежат в [`prompts/`](../prompts/). В GUI: **Справка → Промпт для ИИ** — выбрать, при необходимости **дописать схемы**, скопировать в буфер. Полная документация также доступна в **Справка → Документация** (вшита в exe).
|
||||
|
||||
## Какие файлы
|
||||
|
||||
| Файл | Когда |
|
||||
|------|--------|
|
||||
| [`generate-md.md`](../prompts/generate-md.md) | Базовый: диалект md2gost, чем отличается от обычного MD; UML/Mermaid рисует конвертер |
|
||||
| [`generate-mirea-report.md`](../prompts/generate-mirea-report.md) | Полный отчёт ГОСТ / МИРЭА (`coursework`, `practice`, `vkr`) |
|
||||
| [`generate-pis-custom-report.md`](../prompts/generate-pis-custom-report.md) | Итоговый отчёт ПИС (`PIS_custom`) |
|
||||
| [`emulate-student.md`](../prompts/emulate-student.md) | После любого generate-*: текст про предмет, не отчёт модели о сдаче ТЗ |
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
1. Откройте промпт (файл или GUI). При необходимости добавьте `emulate-student` после базового generate-*.
|
||||
2. В GUI включите нужные схемы (C4, usecase, …) — в конец промпта добавятся макросы и `ai-prompt` из `md2gost.schemes.json`.
|
||||
3. Скопируйте собранный текст в ChatGPT / Claude / Cursor / Copilot.
|
||||
4. Следующим сообщением: тип работы, тема, черновик / требования.
|
||||
5. Сохраните ответ как `.md` и конвертируйте:
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --type coursework --check --strict
|
||||
```
|
||||
|
||||
## Зачем кнопки схем
|
||||
|
||||
Базовый промпт уже говорит: блоки `` ```uml `` / `` ```mermaid `` / `` ```uml-c4 `` конвертер сам превратит в Рисунок. Кнопки нужны, когда модели надо знать **конкретные макросы** выбранной схемы (Person/Rel для C4, actor/usecase и т.д.) — в том числе пользовательских из «Шаблоны UML».
|
||||
|
||||
## Подробнее о синтаксисе
|
||||
|
||||
- [markdown.md](markdown.md)
|
||||
- [schemes.md](schemes.md)
|
||||
- [prompts/README.md](../prompts/README.md)
|
||||
@@ -0,0 +1,64 @@
|
||||
# Быстрый старт
|
||||
|
||||
## Установка
|
||||
|
||||
Нужен Python 3.10+.
|
||||
|
||||
```bash
|
||||
uv sync # окружение по uv.lock (канонический lock-файл)
|
||||
# или
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Для продолжения таблиц/листингов через Word COM (режим `word` по умолчанию) на Windows:
|
||||
|
||||
```bash
|
||||
pip install pywin32
|
||||
```
|
||||
|
||||
Нужны установленный Microsoft Word.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
python -m md2gost # GUI без файла
|
||||
python -m md2gost --gui
|
||||
python -m md2gost report.md -o report.docx --type coursework --check
|
||||
md2gost-gui # то же GUI (entry point)
|
||||
```
|
||||
|
||||
### Windows exe
|
||||
|
||||
В корне репозитория: `build-exe.bat` → `dist\md2gost.exe`.
|
||||
|
||||
- Двойной клик — GUI.
|
||||
- CLI: `md2gost.exe report.md -o report.docx --type coursework`.
|
||||
|
||||
## GUI за минуту
|
||||
|
||||
1. Перетащите `.md` в верхнюю область (или кликните по ней).
|
||||
2. Выберите тип документа (`practice` по умолчанию).
|
||||
3. При необходимости: Настройки → Файлы (титул, задание), **Настройки → Студент…** (ФИО и группа для автотитула), **Настройки → Метаданные…** (свойства DOCX), Настройки → Диаграммы, меню «Шаблоны UML».
|
||||
4. Нажмите «Конвертировать».
|
||||
|
||||
Справка в меню: **Инструкция**, **Схемы**, **Промпт для ИИ**.
|
||||
|
||||
Автотитул (`info_conv.yaml` + блок `title`): [title.md](title.md).
|
||||
|
||||
## Типы документов (кратко)
|
||||
|
||||
| `--type` | Назначение |
|
||||
|----------|------------|
|
||||
| `practice` / `coursework` | Практика / курсовая по ТЗ МИРЭА |
|
||||
| `vkr` | ВКР (раздельный список, графическое приложение) |
|
||||
| `PIS_custom` | Итоговый отчёт по практическим работам |
|
||||
| `APID_coursework` | Курсовая «Архитектура приложений и данных» |
|
||||
|
||||
Подробнее: [types.md](types.md).
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
- Написать отчёт в диалекте md2gost: [markdown.md](markdown.md)
|
||||
- Автотитульник: [title.md](title.md)
|
||||
- Вставить диаграммы: [schemes.md](schemes.md)
|
||||
- Сгенерировать `.md` через ИИ: [prompts.md](prompts.md)
|
||||
@@ -0,0 +1,988 @@
|
||||
УДК 378
|
||||
ББК 65
|
||||
К 43
|
||||
Кириллина Ю.В. Требования к оформлению отчетов по практикам, курсовых работ и
|
||||
ВКР бакалавров и магистров [Электронный ресурс]: Методические указания / Кириллина
|
||||
Ю.В., Лагунова А.Д., Бергер Е.Г. — М.: МИРЭА – Российский технологический
|
||||
университет, 2022. — 1 электрон. опт. диск (CD-ROM)
|
||||
Методические указания знакомят студентов с требованиями по оформлению учебных
|
||||
работ, таких как курсовые работы, отчеты по практикам, выпускная квалификационная
|
||||
работа. Использование методических указаний позволит студентам грамотно оформлять
|
||||
учебные работы, которые они выполняют согласно учебному плану в процессе обучения.
|
||||
Данные методические указания основаны на ГОСТ 7.32 – 2017 Система стандартов по
|
||||
информации, б иблиотечному и издательскому делу. Отчет о научно -исследовательской
|
||||
работе. Структура и правила и оформления.
|
||||
Предназначено для студентов по направлени ям подготовки 09.03.03 Прикладная
|
||||
информатика, 09.0 4.03 Прикладная информатика , а также может быть использовано
|
||||
студентами других направлений подготовки для получения навыков грамотного оформления
|
||||
учебных работ.
|
||||
Методические указания издаются в авторской редакции.
|
||||
Авторский коллектив: Кириллина Юлия Владимировна, Лагунова Анна Дмитриевна, Бергер
|
||||
Екатерина Геннадьевна
|
||||
Рецензенты:
|
||||
Одинцова Мария Александровна , к.т.н., доцент, доцент кафедры «Предметно -
|
||||
ориентированные информационные системы» Института кибербезопасности и
|
||||
цифровых технологий РТУ МИРЭА
|
||||
Системные требования:
|
||||
Наличие операционной системы Windows, поддерживаемой производителем.
|
||||
Наличие свободного места в оперативной памяти не менее 128 Мб.
|
||||
Наличие свободного места в памяти постоянного хранения (на жестком диске) не менее 30 Мб.
|
||||
Наличие интерфейса ввода информации.
|
||||
Дополнительные программные средства: программа для чтения pdf-файлов (Adobe Reader).
|
||||
Подписано к использованию по решению Редакционно-издательского совета
|
||||
МИРЭА — Российский технологический университет.
|
||||
Обьем: 5.39 мб
|
||||
Тираж: 10
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
© Кириллина Ю.В., Лагунова А.Д.,
|
||||
Бергер Е.Г., 2022
|
||||
© МИРЭА – Российский
|
||||
технологический университет, 2022
|
||||
3
|
||||
СОДЕРЖАНИЕ
|
||||
|
||||
ПРЕДИСЛОВИЕ ...................................................................................................... 4
|
||||
1. СТРУКТУРА УЧЕБНОЙ РАБОТЫ .................................................................... 5
|
||||
2. ОФОРМЛЕНИЕ ТЕКСТА ДОКУМЕНТА ........................................................ 12
|
||||
3. ОФОРМЛЕНИЕ ИЛЛЮСТРАЦИЙ И ТАБЛИЦ В УЧЕБНОЙ РАБОТЕ ....... 17
|
||||
4. ОФОРМЛЕНИЕ ТЕКСТА (ЛИСТИНГА) ПРОГРАММЫ В УЧЕБНОЙ
|
||||
РАБОТЕ .................................................................................................................. 27
|
||||
5. ОФОРМЛЕНИЕ СПИСКА ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ В
|
||||
УЧЕБНОЙ РАБОТЕ ............................................................................................... 30
|
||||
6. ОФОРМЛЕНИЕ ПРИЛОЖЕНИЙ В УЧЕБНОЙ РАБОТЕ .............................. 34
|
||||
СПИСОК ИСТОЧНИКОВ И ЛИТЕРАТУРЫ ...................................................... 37
|
||||
ПРИЛОЖЕНИЕ А .................................................................................................. 38
|
||||
ПРИЛОЖЕНИЕ Б................................................................................................... 39
|
||||
ПРИЛОЖЕНИЕ В .................................................................................................. 41
|
||||
ПРИЛОЖЕНИЕ Г................................................................................................... 42
|
||||
ПРИЛОЖЕНИЕ Д .................................................................................................. 44
|
||||
ПРИЛОЖЕНИЕ Е .................................................................................................. 45
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4
|
||||
ПРЕДИСЛОВИЕ
|
||||
|
||||
В процессе обучения студентам направлений подготовки 09.03.03, 09.04.03
|
||||
Прикладная информатика необходимо писать и оформлять курсовые работы, от-
|
||||
четы по различным видам и типам практик, а также выпускную квалификацион-
|
||||
ную работу (далее по тексту — учебные работы). Данные методические указания
|
||||
предназначены для студентов вышеуказанных направлений подготовки.
|
||||
Целью данных методических указаний является поддержка грамотного
|
||||
оформления текстовых документов студентами.
|
||||
Задачами методических указаний являются:
|
||||
• ознакомление студентов с общей структурой учебных работ, таких как
|
||||
курсовая работа, отчет по практике, выпускная квалификационная ра-
|
||||
бота (ВКР);
|
||||
• ознакомление студентов с правилами оформления учебных работ.
|
||||
Основой для создания методических указаний является ГОСТ 7.32 — 2017
|
||||
Система стандартов по информации, библиотечному и издательскому делу.
|
||||
Отчет о научно-исследовательской работе. Структура и правила и оформления.
|
||||
Методические указания составлены в соответствии с:
|
||||
• СМКО МИРЭА 7.5.1/03.П.67- 18 Положение о выпускной квалифика-
|
||||
ционной работе студентов, обучающихся по образовательным про-
|
||||
граммам подготовки бакалавров;
|
||||
• СМКО МИРЭА 7.5.1/03.П.68- 18 Положение о выпускной квалифика-
|
||||
ционной работе студентов, обучающихся по образовательным про-
|
||||
граммам подготовки магистров;
|
||||
• СМКО МИРЭА 7.5.1/04.И.05- 18 Инструкция по организации и прове-
|
||||
дению курсового проектирования.
|
||||
|
||||
|
||||
5
|
||||
1. СТРУКТУРА УЧЕБНОЙ РАБОТЫ
|
||||
|
||||
Учебная работа студента должна иметь определенную структуру.
|
||||
Структура конкретной учебной работы регламентируется методическими
|
||||
указаниями по написанию курсовой работы, методическими указаниями по
|
||||
написанию отчета по практике, методическими указаниями по написанию ВКР,
|
||||
которые разрабатываются преподавателями выпускающей кафедры и издаются
|
||||
редакционно-издательским отделом университета. В се изданные методически е
|
||||
указания выпускающей кафедры студент может найти на сайте университет а в
|
||||
разделе Студентам, далее Библиотека.
|
||||
В тоже время общая структура учебной работы включает в себя:
|
||||
• ый лист;
|
||||
• ие;
|
||||
• ие;
|
||||
• ие;
|
||||
• ием;
|
||||
• ие;
|
||||
• иков;
|
||||
• ия.
|
||||
Титульный лист учебной работы имеет утвержденную форму , выдается
|
||||
преподавателем, который руководит курсовой работой/практикой/ВКР.
|
||||
На титульном листе в обязательном порядке проставляется подпись
|
||||
руководителя учебной работы и студента. В случае, если в кач естве учебной
|
||||
работы выступает курсовая работа, то на не м необходима подпись второго
|
||||
преподавателя, участвующего в процедуре защиты курсовой работы. В слу чае,
|
||||
если в качестве учебной работы выступает ВКР, то на титульном листе
|
||||
необходима подпись заведующего выпускающей кафедрой. Примеры титульных
|
||||
листов различных учебных работ приведены в Приложении А .
|
||||
му и выдается
|
||||
преподавателем, который руководит курсовой работой/практикой/ВКР.
|
||||
аботы) — т, в
|
||||
яется абота). ие подписывается
|
||||
аботы. совых
|
||||
абот, а т а к ж е о т ч е т о в п о п р а к т и к е ие обязательно ждается
|
||||
ой. абот
|
||||
дополнительно согласуется с директор ом института. Факт принятия
|
||||
студентом задания к работе подтверждается подписью студента на задании.
|
||||
6
|
||||
Кроме подписей в задании должны быть указаны дата утверждения задания
|
||||
заведующим кафедрой, дата выдачи задания, дата принятия задания к
|
||||
исполнению. Примеры заданий различных учебных работ приведены в
|
||||
Приложении Б.
|
||||
афической
|
||||
частей в учебной работе устанавливаются методическими указаниями по
|
||||
написанию курсовой работы, методическими указаниями по написанию отчета
|
||||
по практике, методическими указаниями по написанию ВКР .
|
||||
авливается методическими
|
||||
указаниями по написанию курсовой работы, методическими указаниями по
|
||||
написанию отчета по практике, методическими указаниями по написанию ВКР ,
|
||||
а также положениями, приведенными в предисловии данного пособия.
|
||||
Текст основной части учебной работы еобходимости а азделы,
|
||||
азделы, пункты . кты имеют заголовки, четко и
|
||||
ктов.
|
||||
а листе, который расположен ого листа и
|
||||
ия, азделов,
|
||||
иц. иц
|
||||
а быть
|
||||
ая.
|
||||
ия заголовков:
|
||||
• аздела — я;
|
||||
• аздела — я;
|
||||
• ктов — ей.
|
||||
та,
|
||||
ые с абзацевого отступа.
|
||||
аков
|
||||
ия пишется заголовок.
|
||||
а б ы т ь в
|
||||
ﮦ ﮦ аздела,
|
||||
ых точками.
|
||||
Параметры оформления заголовков в учебной работе представлены в табл.
|
||||
2.1. Важно обратить внимание на то, что интервалы между заголовками
|
||||
выставляются в единицах измерения — мм, а не пт.
|
||||
На рис. 2.1 представлен пример внешнего вида заголовков выпускной
|
||||
квалификационной работы и курсовой работы с учетом установленных
|
||||
параметров страниц для учебной работы.
|
||||
7
|
||||
Таблица 2.1. Параметры оформления заголовков в учебной работе
|
||||
Параметр Заголовок
|
||||
первого урﮦовнﮦя
|
||||
Заголовок
|
||||
второго уровня
|
||||
Заголовок
|
||||
третьего уровня
|
||||
ифт
|
||||
Шрﮦифт Times New Roman
|
||||
Нﮦачерﮦтанﮦие полужирﮦнﮦый
|
||||
Подчерﮦкиванﮦие нﮦет
|
||||
Видоизменﮦенﮦие все прﮦописнﮦые нﮦет нﮦет
|
||||
Рﮦазмерﮦ 18 пт 16 пт 14 пт
|
||||
Цвет текста Авто (черﮦнﮦый)
|
||||
Абзац
|
||||
Вырﮦавнﮦиванﮦие По левому крﮦаю
|
||||
Перﮦвая стрﮦока нﮦет
|
||||
Отступ слева 1,25 см
|
||||
Отступ спрﮦава 0 мм
|
||||
Инﮦтерﮦвал перﮦед 0 мм 15 мм 15 мм
|
||||
Инﮦтерﮦвал после 10 мм 10 мм 10 мм
|
||||
Междустрﮦочнﮦый
|
||||
инﮦтерﮦвал
|
||||
Полуторﮦнﮦый
|
||||
Положенﮦие нﮦа
|
||||
ице
|
||||
Не отрывать от
|
||||
следующего
|
||||
С новой страницы
|
||||
Не отрывать от
|
||||
следующего
|
||||
Запрет висящих строк
|
||||
Нﮦе отрывать от
|
||||
следующего
|
||||
Запрет висящих строк
|
||||
|
||||
|
||||
Рисунок 2.1. Пример внешнего вида заголовков
|
||||
|
||||
8
|
||||
азделы «СОДЕРЖАНИЕ», «ВВЕДЕНИЕ», «ЗАКЛЮЧЕНИЕ», «СПИСОК
|
||||
ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ », «П РИЛОЖЕНИЕ»/«ПРИЛОЖЕНИЯ»
|
||||
уются.
|
||||
Разделы «СОДЕРЖАНИЕ» и «СПИСОК ИСПОЛЬЗОВАННЫХ
|
||||
ИСТОЧНИКОВ» выравниваются по центру страницы.
|
||||
ие
|
||||
азделяют точкой.
|
||||
е Word используется
|
||||
вкладка «Стили» (рис. 2.2). ﮦ ов
|
||||
о
|
||||
ить азу
|
||||
ия.
|
||||
|
||||
|
||||
Рисунок 2.2. Использование вкладки «Стили» в текстовом редакторе Word
|
||||
|
||||
ить
|
||||
а «Стили» ять
|
||||
овые стили (рис. 2.3).
|
||||
ые соответствующими стилями, позволяют
|
||||
ие.
|
||||
ктов должны
|
||||
ии, и
|
||||
соответственно наоборот. Для обеспечения этого содержание учебной работы
|
||||
должно формироваться с помощью встроенной функции Оглавление.
|
||||
ИЕ»,
|
||||
ием по
|
||||
у. Важно обратить внимание на значения интервала перед самим
|
||||
заголовком и после него для того, чтобы обеспечить нужные интервалы между
|
||||
заголовков и последующими пунктами самого содержания учебной работы.
|
||||
ю
|
||||
«Ссылки» → а ю и с
|
||||
ителя строки «. . . . . . . .» ( рис. 2.4).
|
||||
|
||||
9
|
||||
ить и
|
||||
овать.
|
||||
|
||||
|
||||
Рисунок 2.3. Редактирование заголовков в текстовом редакторе Word с
|
||||
использование диалогового окна «Стили»
|
||||
|
||||
|
||||
Рисунок 2.4. Формирование содержания через Автособираемое оглавление
|
||||
|
||||
иц,
|
||||
ом
|
||||
овить поле».
|
||||
|
||||
10
|
||||
При создании содержания слово ОГЛАВЛЕНИЕ заменяется в ручном
|
||||
режиме на СОДЕРЖАНИЕ с выставлением необходимых параметров,
|
||||
характерных для заголовков первого уровня.
|
||||
ой
|
||||
текст» без отступа первой строки аю.
|
||||
я,
|
||||
ии
|
||||
ыми буквами (рис. 2.5), аналогично тому, как они
|
||||
оформлены в учебной работе за исключением того, что полужирное начертание
|
||||
не применяется.
|
||||
|
||||
|
||||
Рисунок 2.5. Пример оформления содержания в ВКР
|
||||
|
||||
11
|
||||
ии текст попадает иц
|
||||
кте отсутствует отточие ицы, то часть
|
||||
очку.
|
||||
|
||||
12
|
||||
2. ОФОРМЛЕНИЕ ТЕКСТА ДОКУМЕНТА
|
||||
|
||||
ты учебной работы ой
|
||||
е листа.
|
||||
В случае представления учебной работы в бумажном виде она а быть
|
||||
мата А4.
|
||||
Учебная работа ых
|
||||
ﮦ илами.
|
||||
Учебная работа проверяется руководителем на соблюдение правил
|
||||
оформления, в случае несоответствия работы она не допускается до защиты и
|
||||
выставления оценки, и направляется на доработку.
|
||||
Текст документа должен быть кратким, четким и не допускать различных
|
||||
толкований. В докуме нтах должны применяться научно-технические те рмины,
|
||||
обозначения и определен ия общеп ринятые в научно-технической лите ратуре
|
||||
Стиль изложения текста в учебной работе строго нау чный, подстиль —
|
||||
собственно-научный. е допускается:
|
||||
• ицизмы,
|
||||
ализмы;
|
||||
• ия;
|
||||
• усской
|
||||
тами и
|
||||
ая
|
||||
овка;
|
||||
• и
|
||||
ﮦ .
|
||||
Текст учебной работы печатается, соблюдая следующие размеры полей:
|
||||
левое — 30 мм, правое — 10 мм, верхнее и нижнее — 20 мм. Абзацный отступ
|
||||
должен быть одинаковым по всему тексту и равен 1,25 см [1].
|
||||
а листах в рамках еще
|
||||
незаконченного раздела (см. Приложение В).
|
||||
иц.
|
||||
Не допускается размещать заголовки в кон це страницы, а основной текст
|
||||
переносить на следующую страницу.
|
||||
Текст ифтом
|
||||
ого цвета , размер шрифта — 14 пт, тип шрифта для основного текста
|
||||
учебной работы — Times NewтRoman.
|
||||
ой текст» (табл. 2.1).
|
||||
13
|
||||
ифт»,
|
||||
а знаков абзаца и других скрытых знаков
|
||||
форматирования ца абзаца и
|
||||
аки» (ая» →
|
||||
ифт» → ¶).
|
||||
|
||||
Таблица 2.1. Параметры оформления основного текста учебной работы
|
||||
Параметр Значение
|
||||
ифт
|
||||
Шрﮦифт Times New Roman
|
||||
Нﮦачерﮦтанﮦие Обычнﮦый
|
||||
Подчерﮦкиванﮦие нﮦет
|
||||
Видоизменﮦенﮦие нﮦет
|
||||
Рﮦазмерﮦ 14 пт
|
||||
Цвет текста Авто (черный)
|
||||
Абзац
|
||||
Выравнивание По ширине
|
||||
Первая строка 1,25 см.
|
||||
Отступ слева нет
|
||||
Отступ справа нﮦет
|
||||
Интервал перﮦед нﮦет
|
||||
Интервал после нет
|
||||
Междустрочный интервал Полуторный
|
||||
Положение на странице Запрет висящих строк
|
||||
|
||||
Пробел ставится после любого знака пунктуации, но не перед ним. При этом
|
||||
не ставятся п робелы после отк рывающих скобок и кавычек, так же как и пе ред
|
||||
закрывающими скобками и кавычками.
|
||||
Кавычки в русском тексте (включая список использова нных источ ников)
|
||||
имеют форму «…», а в английском — "…".
|
||||
ая.
|
||||
ицы учебной работы оставляемыми по
|
||||
ифт — ﮦ12 пт).
|
||||
ﮦ ицы
|
||||
е ставится:
|
||||
• титульный лист;
|
||||
• ие;
|
||||
• ие.
|
||||
е
|
||||
аки, такие как
|
||||
«+», «–», «=», «>», «<» и так далее. В тексте их следует писать словами: «плюс»,
|
||||
ьше».
|
||||
аки:
|
||||
14
|
||||
1. ак дефиса «-» обелами и используется:
|
||||
• жащих дефисы (во-первых,
|
||||
физико-математический, кто-либо);
|
||||
• валов (100-150 Мб).
|
||||
2. уса «–» ия.
|
||||
3. е «—обелами и используется в соответствии с
|
||||
ктуации (Классификаторﮦ —это
|
||||
систематизированный …).
|
||||
абские
|
||||
талов, полугодий,
|
||||
ами.
|
||||
ами, ы
|
||||
иями (… в начале XXI века).
|
||||
ых
|
||||
ий (… во 2 разделе, … характерно для середины 1980 годов).
|
||||
ﮦ ой и той же
|
||||
его числа
|
||||
(…составляет 200, 250, 300 руб.)ица
|
||||
ﮦ е (… длиной 20-25 см).
|
||||
ﮦ иц
|
||||
ﮦ ами, а числа без
|
||||
ﮦ ицы до девяти
|
||||
— словами (провести испытан ия семи т руб, каждая дли ной 5 м) . ые
|
||||
обей с ипользованием
|
||||
запятой при написании (20,5 см).
|
||||
ый и ый спис ки уются стилем
|
||||
ой текст».
|
||||
|
||||
Таблица 2.2. Параметры оформления списков
|
||||
Параметр Значение
|
||||
Марﮦкирﮦованﮦнﮦый список –, ―, ●, ■, ○
|
||||
Нﮦумерﮦованﮦнﮦый список 1.
|
||||
а)
|
||||
Положенﮦие марﮦкерﮦа (нﮦомерﮦа) Отступ 1,25 см
|
||||
Положенﮦие текста Табуляция после 2,25 см
|
||||
Отступ 2,25 см
|
||||
|
||||
ﮦ а во всем
|
||||
ькой
|
||||
15
|
||||
чивается — кт в
|
||||
чивается точкой).
|
||||
ﮦ ой буквы и
|
||||
чиваться точкой (рис. 2.1-2.3).
|
||||
|
||||
|
||||
Рисунок 2.1. Пример 1 оформления списка в учебной работе
|
||||
|
||||
|
||||
Рисунок 2.2. Пример 2 оформления списка в учебной работе
|
||||
|
||||
16
|
||||
|
||||
Рисунок 2.3. Пример 3 оформления списка в учебной работе
|
||||
|
||||
В случае необходимости применения для нумерованного списка строчных
|
||||
букв русского алфавита, список начинают с буквы «а» (за исключением букв ё,
|
||||
з, й, о, ч, ъ, ы, ь) [1].
|
||||
Внутри списка не могут располагаться иллюстрации и таблицы.
|
||||
При выполнении учебной работы необходимо соблюдать равномерную
|
||||
плотность и четкость изображения по все й работе. Все линии, буквы, цифры и
|
||||
знаки должны иметь одинаковую контрастность по всему тексту.
|
||||
|
||||
|
||||
17
|
||||
3. ОФОРМЛЕНИЕ ИЛЛЮСТРАЦИЙ И ТАБЛИЦ В
|
||||
УЧЕБНОЙ РАБОТЕ
|
||||
|
||||
Количество иллюстраций в учебной работе должно быть достаточ ным для
|
||||
пояснения излагаемого текста, в некоторых случаях обязательные иллюстрации
|
||||
регламентируются методическими указаниями по написанию курсовой работы,
|
||||
методическими указаниями по написанию отчета по практике, методическими
|
||||
указаниями по написанию ВКР.
|
||||
о после
|
||||
ице (по
|
||||
возможности ближе к соответствующим частям текста учебной работы) с
|
||||
у без абзацного отступа и первой строки.
|
||||
ами.
|
||||
аздела —
|
||||
ﮦ ﮦ азделяются точкой.
|
||||
ации
|
||||
ации (табл. 3.1).
|
||||
|
||||
Таблица 3.1. Параметры оформления подписи иллюстраций
|
||||
Параметр Значение
|
||||
ифт
|
||||
Шрифт Times New Roman
|
||||
Начертание Полужирﮦнﮦый
|
||||
Подчеркивание нет
|
||||
Видоизменﮦение нет
|
||||
Размер 12 пт
|
||||
Цвет текста Авто (черﮦнﮦый)
|
||||
Абзац
|
||||
Выравнивание По центру
|
||||
Первая строка 0 см
|
||||
Отступ слева 0 см
|
||||
Отступ спрﮦава нет
|
||||
Интервал перﮦед 0 мм
|
||||
Интервал после 6 мм
|
||||
Междустрочный интервал Одинарный
|
||||
Положение на странице Запрет висящих строк
|
||||
|
||||
ия
|
||||
е ставится . Предложение, в котором автор ссылается на
|
||||
рисунок, должно заканчиваться точкой, а не двоеточие м, т а к к а к э т о является
|
||||
нарушением правил пунктуации. (рис. 3.1).
|
||||
ием
|
||||
а.
|
||||
|
||||
18
|
||||
|
||||
Рисунок 3.1. Пример оформления рисунка с учетом установленных параметров
|
||||
страниц учебной работы
|
||||
|
||||
углых скобках или
|
||||
ﮦ :
|
||||
• план проекта иллюстрирует диаграмма Ганта (Рисунок 1.1);
|
||||
• дерево функций представлено на Рисунке 1.1.
|
||||
о
|
||||
ия по часовой
|
||||
а 90 яется с
|
||||
ую с помощью команды главного меню «Макет» . При
|
||||
размещении рисунка на отдельной странице с альбомной ориентацией
|
||||
необходимо установить в Параметрах страницы вертикальное выравнивание
|
||||
по центру.
|
||||
Страницы с альбомной ориентацией предназначены только для
|
||||
рисунков и таблиц, нельзя размещать на них основной текст (Приложение Г,
|
||||
рис. Г.1-Г.2).
|
||||
|
||||
19
|
||||
Сначала устанавливаются разрывы страницы, начиная со следующей
|
||||
страницы, а потом меняется ориентация с книжной на альбомную (рис. 3.2-3.3).
|
||||
|
||||
|
||||
Рисунок 3.2. Переход от книжной ориентации к альбомной
|
||||
|
||||
|
||||
Рисунок 3.3. Пример оформления рисунка на странице с альбомной ориентацией
|
||||
|
||||
20
|
||||
В случае, если рисунок завершает раздел, подраздел или пункт
|
||||
рекомендуется после него сформулиро вать заключительное предложение. Для
|
||||
раздела это будет заключительное предложение, подводящее итоги по
|
||||
выполненной работе в соответствующем разделе. Для подраздела или пункта это
|
||||
будет завершающее предложение, обеспечивающее переход к следующему
|
||||
подразделу или пункту (рис. 3.4-3.5).
|
||||
|
||||
|
||||
Рисунок 3.4. Пример завершения раздела и переход к следующему
|
||||
|
||||
Все иллюстрации должны иметь границы четко по представляемому
|
||||
рисунку, схеме и т.д., и не допускается пустая, неинформативная часть в
|
||||
иллюстрации, занимающая место, предназначеннное для основного текста.
|
||||
На рис. 3.6 представлена правильная и неправильная обрезка границ
|
||||
иллюстрации.
|
||||
Вне зависимости от того, каков формат представления учебной работы:
|
||||
распечатанная бумажная версия или электронный файл, все иллюстрации
|
||||
должны удовлетворять требованию их четкого воспроизведения, то есть должна
|
||||
быть обеспечена читабельность иллюстраций без необходимости прибегать к
|
||||
увеличению масштаба представленного материала.
|
||||
|
||||
|
||||
21
|
||||
|
||||
Рисунок 3.5. Пример завершения подраздела и переход к следующему подразделу в
|
||||
курсовой работе
|
||||
|
||||
|
||||
Рисунок 3.6. Пример правильной обрезки границ иллюстрации (левая часть) и
|
||||
неправильной обрезки границ илллюстрации (правая часть)
|
||||
|
||||
22
|
||||
Кроме того, рисунки, представляющие собой чертежи, графики, диаграммы,
|
||||
схемы, помещаемые в учебной работе , должны соответствовать требованиям
|
||||
стандартов Единой системы конструкторской документации (ЕСКД) .
|
||||
ия
|
||||
показателей.
|
||||
ие таблицы атким.
|
||||
ад таблицей.
|
||||
ие таблицы пишется без абзацного отступа и первой строки у
|
||||
це
|
||||
е ставится.
|
||||
ами аздела —
|
||||
ﮦ ﮦ азделяются точкой.
|
||||
ом
|
||||
еобходимости,
|
||||
ту.
|
||||
ﮦ ие ячеек
|
||||
е допускается.
|
||||
ед
|
||||
е Word).
|
||||
Параметры оформления таблицы в учебной работе представлены в табл. 3.2.
|
||||
|
||||
Таблица 3.2. Параметры оформления таблицы
|
||||
Параметр Надпись таблицы Текст таблицы
|
||||
ифт
|
||||
Шрифт Times New Roman
|
||||
ие Курсив Обычный, полужирный,
|
||||
курсив
|
||||
Подчеркиванﮦие нет
|
||||
Видоизменение нет
|
||||
Размер 12 пт
|
||||
Цвет текста Авто (черный)
|
||||
Абзац
|
||||
Выравнивание По левому краю По центру, по левому
|
||||
краю
|
||||
Первая стрﮦока 0 см
|
||||
Отступ слева 0 см
|
||||
Отступ справа нет
|
||||
Интервал перед 6 мм 0 мм
|
||||
Интервал после 0 мм
|
||||
Междустрочный интервал Одинﮦарный
|
||||
Положение нﮦа странице Запрет висящих строк
|
||||
|
||||
23
|
||||
а все таблицы учебной работы ы ссылки в тексте.
|
||||
углых скобках или включаются в
|
||||
ие. Если ссылка на таблицу завершает предложение, то в конце
|
||||
предложения ставится точка.
|
||||
ием
|
||||
углых скобках
|
||||
ﮦ :
|
||||
• далее представлен планﮦпроекта (Таблица 3.1);
|
||||
• планﮦпроекта представлен в Таблице 3.1 (рис. 3.7).
|
||||
|
||||
|
||||
Рисунок 3.7. Пример представления таблицы
|
||||
|
||||
ие
|
||||
ую
|
||||
угими частями пишется
|
||||
адпись таблицы»
|
||||
(рис. 3.8).
|
||||
Не допускаются таблицы в одну строчку, а также не допускается оставлять
|
||||
на странице только заголовок к таблице или заголовок с заголовками граф
|
||||
(Приложение Г, рис. Г.3).
|
||||
ации
|
||||
ед их
|
||||
ием.
|
||||
Все таблицы в учебной работе должны быть оформлены единообразно с
|
||||
точки зрения применяемого стиля для оформления заголовков и подзаголовков
|
||||
граф, а также строк таблицы.
|
||||
|
||||
|
||||
|
||||
24
|
||||
|
||||
Рисунок 3.8. Пример оформления переноса таблицы в учебной работе
|
||||
|
||||
ия,
|
||||
ия
|
||||
е
|
||||
о под
|
||||
мулой.
|
||||
ием
|
||||
мул Microsoft Equation или MathType.
|
||||
а вкладке «Вставка» →«Уравнение» (рис.
|
||||
3.9).
|
||||
|
||||
|
||||
Рисунок 3.9. Возможности контекстного меню «Уравнение»
|
||||
|
||||
|
||||
25
|
||||
Не допускается формулы оформлять как основной текст, основным
|
||||
шрифтом.
|
||||
мулы, ивается по
|
||||
у. а
|
||||
ока.
|
||||
а ака
|
||||
ус ( –ия (×) или
|
||||
ия яется.
|
||||
ию.
|
||||
тов следует
|
||||
ости, в какой
|
||||
муле.
|
||||
ают без абзацного отступа со слова «где»
|
||||
его.
|
||||
Значение каждого символа и числового коэффицие нта следует давать с
|
||||
новой строки, располагая символы один под другим (рис. 3.10).
|
||||
|
||||
|
||||
Рисунок 3.10. Пример оформления последовательности формул в учебной работе
|
||||
|
||||
|
||||
26
|
||||
Если формула является концом предложения, то ставится точка, если после
|
||||
формулы предложение продолжается — запятая.
|
||||
Формулы нумеруются последовательно в пределах раздела арабскими
|
||||
цифрами в круглых скобках, выровненных по правому краю. Нумеруются только
|
||||
те формулы, на которые имеются ссылки в тексте.
|
||||
Ссылки в тексте на номер формулы даются в круглых скобках.
|
||||
Вне зависимости от способа выполнения учебной работы качество
|
||||
напечатанного текста и оформления иллюстраций, таблиц, распечаток программ
|
||||
должно удовлетворять требованию их четкого воспроизведения.
|
||||
27
|
||||
4. ОФОРМЛЕНИЕ ТЕКСТА (ЛИСТИНГА) ПРОГРАММЫ В
|
||||
УЧЕБНОЙ РАБОТЕ
|
||||
|
||||
В учебной работе могут содержаться отдельные фрагменты программ (ли-
|
||||
стинг), разработанных студентом.
|
||||
Название листинга пишется в одну строку с номером, через тире, с большой
|
||||
буквы, перед листингом. Точка в конце не ставится. Пример, «Листинг 1.7 —
|
||||
Процедура открытия программы».
|
||||
При ссылке на отдельные строки программы они нумеруются в порядке воз-
|
||||
растания с шагом 10. После ссылки на листинг в фигурных скобках привод ятся
|
||||
номера строк. Пример «Листинг 3.2 {30, 50, 140}», «Листинг 3.2 {20- 80}».
|
||||
Листинг оформляется в рамку (рис. 4.1-4.2).
|
||||
|
||||
|
||||
Рисунок 4.1. Пример 1 оформления листинга
|
||||
|
||||
|
||||
Рисунок 4.2. Пример 2 оформления листинга
|
||||
|
||||
28
|
||||
Параметры оформления листинга представлены в табл. 4.1.
|
||||
|
||||
Таблица 4.1. Параметры оформления листинга
|
||||
Листинг
|
||||
Надпись листинга
|
||||
Шрифт Шрифт
|
||||
Шрифт Times New Roman
|
||||
Начертание Курсив
|
||||
Размер 12 пт
|
||||
Цвет текста Авто (черный)
|
||||
Подчеркивание нет
|
||||
Видоизменение нет
|
||||
Интервал
|
||||
Масштаб 100%
|
||||
Интервал Обычный
|
||||
Смещение Нет
|
||||
Анимация Вид Нет
|
||||
Абзац Отступы и интервалы
|
||||
Выравнивание По левому краю
|
||||
Отступ слева 0 см
|
||||
Отступ справа 0 см
|
||||
Первая строка нет
|
||||
Интервал перед 6 мм
|
||||
Интервал после 0 мм
|
||||
Междустрочный Одинарный
|
||||
Положение на странице Разбивка на страницы Не отрывать от следующего
|
||||
С новой страницы
|
||||
Содержание листинга
|
||||
Шрифт Шрифт
|
||||
Шрифт Courier New
|
||||
Начертание Обычный
|
||||
Размер 10 пт
|
||||
Цвет текста Авто (черный)
|
||||
Подчеркивание Нет
|
||||
Видоизменение нет
|
||||
Интервал
|
||||
Масштаб 100%
|
||||
Интервал Обычный
|
||||
Смещение Нет
|
||||
Анимация Вид Нет
|
||||
Абзац
|
||||
Отступы и интервалы
|
||||
Выравнивание По левому краю
|
||||
Отступ слева 0 см
|
||||
Отступ справа 0 см
|
||||
Первая строка 0 см
|
||||
Интервал перед 0 мм
|
||||
Интервал после 0 мм
|
||||
Междустрочный Одинарный
|
||||
|
||||
29
|
||||
Основной текст (строка) после листинга печатается с интервалом перед 6
|
||||
мм.
|
||||
Весь текст программы целесообразно выносить в приложени е.
|
||||
|
||||
30
|
||||
5. ОФОРМЛЕНИЕ СПИСКА ИСПОЛЬЗОВАННЫХ
|
||||
ИСТОЧНИКОВ В УЧЕБНОЙ РАБОТЕ
|
||||
|
||||
Все заимствованные части учебной работы (автором которых не является
|
||||
студент) должны быть сопровождены ссылкой на источник , тип ссылки —
|
||||
затекстовая (см. ГОСТ Р 7.0.5- 2008). Список должен содержать сведения об
|
||||
источниках, использованных при составлении учебной работы [1].
|
||||
Список использованных источников должен включать библиографические
|
||||
записи на документы, использованные при составлении учебной работы, ссылки
|
||||
на которые оформляют арабскими цифрами в квадратных скобках.
|
||||
Сведения об источниках следует располагать в порядке появления ссылок
|
||||
на источники в тексте учебной работы и нумеровать арабскими цифрами с
|
||||
точкой и печатать с абзацного отступа в 1,25 см.
|
||||
При оформлении раздела СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
на первой строке листа печатается соответствующее наименование р аздела,
|
||||
отформатированное по параметрам для заголовка первого уровня.
|
||||
Для учебных работ вида курсовой работы или отчета по практике список
|
||||
использованных источников делить на разделы не требуется. Требованиями к
|
||||
источникам в данном случае являются следующие:
|
||||
• количество источников от 5 до 7;
|
||||
• год издания источника — за последние 5 лет к моменту написания и
|
||||
представления учебной работы (Приложение Д, рис. Д.1).
|
||||
В случае выпускной квалификационной рабо ты список использованных
|
||||
источников делится на разделы, в каждом и которых должно быть представлено
|
||||
не менее 10 источников, годом издания не больше, чем 5 лет к моменту защиты
|
||||
выпускной квалификационной работы.
|
||||
Параметры оформления заголовков разделов в списке использованных
|
||||
источников представлены в табл. 5.1.
|
||||
Внутри раздела источники распределяются в порядке упоминания их в
|
||||
выпускной квалификационной работе и нум еруются (рис. 5.1). В данном случае
|
||||
номер источника будет состоять из номера разд ела, к которому он относится, и
|
||||
номера по порядку.
|
||||
ики следует давать в виде
|
||||
арабских цифр, заключенных в квадратные скобки, указывающих порядковый
|
||||
номер источника по списку, например, [1], [2, 3], [1.5], [2.18].
|
||||
В случае цитирования указываются страницы книги, статьи или другого
|
||||
источника, из которых взята цитата, например, [2.18, c. 21-25].
|
||||
|
||||
31
|
||||
Таблица 5.1. Параметры оформления заголовков разделов
|
||||
в списке использованных источников
|
||||
Параметр Значение
|
||||
Шрифт
|
||||
Шрифт Times New Roman
|
||||
Начертание Обычный
|
||||
Подчеркивание нет
|
||||
Видоизменение Все прописные
|
||||
Размер 14 пт
|
||||
Цвет текста Авто (черный)
|
||||
Абзац
|
||||
Выравнивание По центру
|
||||
Первая строка нет
|
||||
Отступ слева 1,25 см.
|
||||
Отступ справа нет
|
||||
Интервал перед 6 мм
|
||||
Интервал после 6 мм
|
||||
Междустрочный интервал Полуторный
|
||||
Положение на странице Не отрывать от следующего
|
||||
|
||||
|
||||
Рисунок 5.1. Пример оформления списка использованных источников с делением на
|
||||
разделы
|
||||
|
||||
32
|
||||
На рис. 5. 2 приведен пример основного текста учебной работы со ссылкой
|
||||
на источник.
|
||||
|
||||
|
||||
Рисунок 5.2. Пример оформления ссылок в тексте учебной работы
|
||||
|
||||
На рис. 5. 3 приведен пример основного текста учебной работы со ссылкой
|
||||
на источник при цитировании. При цитировании цитируемая фраза ставится в
|
||||
кавычки.
|
||||
|
||||
|
||||
Рисунок 5.3. Пример оформления ссылок в тексте при цитировании.
|
||||
|
||||
33
|
||||
В основном тексте ВВЕДЕНИЯ и ЗАКЛЮЧЕНИЯ ссылки на
|
||||
источники не указываются.
|
||||
Более подробно порядок оформления различных источников представлен в
|
||||
ГОСТ 7.32 — 2017 Система стандартов по информации, библиотечному и
|
||||
издательскому делу. Отчет о научно -исследовательской работе. Структура и
|
||||
правила и оформления. Приложение Е на странице 26 указанного документа.
|
||||
34
|
||||
6. ОФОРМЛЕНИЕ ПРИЛОЖЕНИЙ В УЧЕБНОЙ РАБОТЕ
|
||||
|
||||
яющий текст учебной работы, допускается помещать в
|
||||
ие учебной работы а
|
||||
последующих ее листах.
|
||||
иложения могут выноситься:
|
||||
• ом, так и
|
||||
ия;
|
||||
• уемого объекта
|
||||
ия;
|
||||
• асчеты;
|
||||
• сложные алгоритмы;
|
||||
• аботы;
|
||||
• тов;
|
||||
• тов;
|
||||
• экранные формы инормацинной системы;
|
||||
• листинги программ;
|
||||
• оизводство
|
||||
ии).
|
||||
мата А4. Допускается
|
||||
мата A3 по ГОСТ 2.301.
|
||||
В учебной работе приложение может быть одно или несколько.
|
||||
В случае, если в учебной работе будет только одно приложение , т о н а
|
||||
следующем листе после списка использованных источников заголовком первого
|
||||
уровня оформляется заголовок ПРИЛОЖЕНИЕ. И на этом же листе размещается
|
||||
необходимый дополняющий материал (рис. Д.1, Приложение Д).
|
||||
В случае необходимости оформить несколько приложений в учебной работе
|
||||
их а я с А , з а
|
||||
ﮦ «Приложение А».
|
||||
ь
|
||||
ия в
|
||||
ой текст» (рис.
|
||||
6.1).
|
||||
ием
|
||||
ого стилем
|
||||
я».
|
||||
35
|
||||
|
||||
Рисунок 6.1. Пример оформления раздела ПРИЛОЖЕНИЯ
|
||||
|
||||
а
|
||||
ﮦ «Приложение А.1», «Приложение А.2», если они связаны по
|
||||
смыслу.
|
||||
ый по
|
||||
ой текст» (рис. 6.2).
|
||||
|
||||
|
||||
Рисунок 6.2. Пример оформления отдельного приложения в составе раздела
|
||||
ПРИЛОЖЕНИЯ
|
||||
|
||||
36
|
||||
Таблицы и иллюст рации, соде ржащиеся в п риложении, нумеруются
|
||||
аналогично таблицам и рисунк а м в о сновном тексте учебной работы.
|
||||
Иллюстрации каждого пр иложения обоз начают отдель ной нумерацией
|
||||
арабскими циф рами с добавлен ием пе ред циф рой обоз начения приложе ния,
|
||||
например, «… представлено на Рисунке Б.1», «… отражен в таблице В.1».
|
||||
В тексте докумен та на все п риложения должн ы быть да ны ссылки.
|
||||
Приложения располагают в порядке ссылок на них в тексте учебной работы.
|
||||
При ссылке следует писать слово «П риложение» с большой буквы с
|
||||
указанием порядкового номера, например «…представлено в Приложении Б».
|
||||
Перечень материала, оформляемого в виде приложения, определяется
|
||||
обучающимся совместно с руководителем на основе методических
|
||||
рекомендаций по курсовой работе, методических рекомендаций по подготовке
|
||||
отчета по практике, методических рекомендаций по написанию ВКР.
|
||||
Применительно к ВКР бакалавра или магистра обязательным приложением
|
||||
к ней является приложение под названием Графический материал, который
|
||||
представляет собой слайды презентации с основными результатами,
|
||||
полученными в ходе написания ВКР.
|
||||
|
||||
37
|
||||
СПИСОК ИСТОЧНИКОВ И ЛИТЕРАТУРЫ
|
||||
|
||||
1. ГОСТ 7.32 — 2017 Система стандартов по информации,
|
||||
библиотечному и издательскому делу. Отчет о научно- исследовательской
|
||||
работе. Структура и правила и оформления — URL:
|
||||
https://www.rea.ru/ru/org/managements/orgnirupr/Documents/gost_7.32 -2017.pdf
|
||||
(дата обращения 20.11.2022)
|
||||
|
||||
38
|
||||
ПРИЛОЖЕНИЕ А
|
||||
|
||||
|
||||
Рисунок А.1. Общий вид титульного листа ВКР
|
||||
|
||||
|
||||
Рисунок А.2. Общий вид титульного листа курсовой работы
|
||||
|
||||
39
|
||||
ПРИЛОЖЕНИЕ Б
|
||||
|
||||
|
||||
Рисунок Б.1. Общий вид одностороннего задания на курсовую работу
|
||||
|
||||
|
||||
Рисунок Б.2. Общий вид двустороннего задания на практику
|
||||
|
||||
40
|
||||
|
||||
Рисунок Б.3. Общий вид двустороннего задания на ВКР (лицевая сторона)
|
||||
|
||||
|
||||
Рисунок Б.4. Общий вид задания на ВКР (оборотная сторона и окончание задания )
|
||||
|
||||
41
|
||||
ПРИЛОЖЕНИЕ В
|
||||
|
||||
|
||||
Рисунок В.1. Пример недопустимой ситуация в учебной работе, полупустая страница при условии,
|
||||
что подраздел еще не закончился
|
||||
|
||||
42
|
||||
ПРИЛОЖЕНИЕ Г
|
||||
|
||||
|
||||
Рисунок Г.1. Пример 1 неправильного использования страницы с альбомной
|
||||
ориентацией
|
||||
|
||||
|
||||
Рисунок Г.1. Пример 2 неправильного использования страницы с альбомной
|
||||
ориентацией
|
||||
|
||||
43
|
||||
|
||||
Рисунок Г.3. Неверное представление таблицы в учебной работе
|
||||
|
||||
|
||||
44
|
||||
ПРИЛОЖЕНИЕ Д
|
||||
|
||||
|
||||
Рисунок Д.1. Пример оформления списка использованных источников без деления на
|
||||
разделы
|
||||
|
||||
|
||||
|
||||
|
||||
45
|
||||
ПРИЛОЖЕНИЕ Е
|
||||
|
||||
|
||||
Рисунок Е.1. Оформление ПРИЛОЖЕНИЯ в учебной работе
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
46
|
||||
СВЕДЕНИЯ ОБ АВТОРАХ
|
||||
|
||||
Бергер Екатерина Геннадьевна, к.э.н., доцент кафедры практической и при-
|
||||
кладной информатики Института информационных технологий
|
||||
Кириллина Юлия Владимировна, к.э.н., доцент, доцент кафедры практиче-
|
||||
ской и прикладной информатики Института информационных технологий
|
||||
Лагунова Анна Дмитриевна, к.э.н., доцент кафедры практической и при-
|
||||
кладной информатики Института информационных технологий
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# Схемы и диаграммы
|
||||
|
||||
В отчёте блок кода с языком диаграммы превращается в **Рисунок** (PNG или SVG). PNG руками экспортировать не нужно: md2gost вызывает PlantUML (jar / Kroki), локальный Mermaid (браузер / QuickJS), локальный IDEF0 (Pillow) или DFD через [data-flow-diagram](https://github.com/pbauermeister/dfd) (вшитый / системный Graphviz или Kroki).
|
||||
|
||||
## Синтаксис в markdown
|
||||
|
||||
````markdown
|
||||
%usecase1 Диаграмма прецедентов +listing
|
||||
|
||||
```uml
|
||||
@startuml
|
||||
actor Student
|
||||
Student --> (Login)
|
||||
@enduml
|
||||
```
|
||||
````
|
||||
|
||||
Широкая схема:
|
||||
|
||||
````markdown
|
||||
%arch1 Архитектура +landscape
|
||||
|
||||
```uml-c4
|
||||
Person(user, "Студент")
|
||||
System(app, "Портал")
|
||||
Rel(user, app, "логин")
|
||||
```
|
||||
````
|
||||
|
||||
| Оградка | Что происходит |
|
||||
|---------|----------------|
|
||||
| `` ```uml `` / `` ```plantuml `` | Сырой PlantUML (`@startuml`…`@enduml`) |
|
||||
| `` ```uml-<id> `` / `` ```<id> `` | Схема из `md2gost.schemes.json` (обёртка и `!include` добавляются сами) |
|
||||
| `` ```mermaid `` / `` ```mmd `` | Mermaid: браузер / QuickJS / Kroki |
|
||||
| `` ```idef0 `` / `` ```uml-idef0 `` | IDEF0 (FIPS 183): локальный рендер, без PlantUML / Kroki |
|
||||
| `` ```dfd `` / `` ```uml-dfd `` / `` ```data-flow-diagram `` | DFD ([pbauermeister/dfd](https://github.com/pbauermeister/dfd)): вшитый Graphviz / PATH / Kroki |
|
||||
|
||||
## Встроенные схемы
|
||||
|
||||
Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске встроенные схемы (`author: md2gost`) синхронизируются с шаблоном: новые id добавляются, устаревшие (например удалённый `bpmn`) убираются, содержимое обновляется. Схемы с пустым или другим `author` не трогаются. Если правите встроенную в GUI и сохраняете — `author` сбрасывается, чтобы миграция не затёрла правки.
|
||||
|
||||
| id | Название | Оградка |
|
||||
|----|----------|---------|
|
||||
| `c4` | C4 Container | `` ```uml-c4 `` / `` ```c4 `` |
|
||||
| `c4context` | C4 Context | `` ```uml-c4context `` |
|
||||
| `c4component` | C4 Component | `` ```uml-c4component `` |
|
||||
| `usecase` | Прецеденты | `` ```uml-usecase `` / `` ```usecase `` |
|
||||
| `archimate` | ArchiMate 3.2 | `` ```uml-archimate `` / `` ```archimate `` |
|
||||
|
||||
Для схем в теле пишите **только макросы** (Person, Rel, actor, Business_Actor, …) — без `@startuml` и без `!include`, если схема сама их добавляет.
|
||||
|
||||
### C4 (пример макросов)
|
||||
|
||||
`Person`, `System`, `System_Ext`, `Container`, `ContainerDb`, `Rel` / `Rel_R`…
|
||||
|
||||
### Use Case
|
||||
|
||||
`actor`, `usecase`, связи `-->`, `<<include>>`, `<<extend>>`.
|
||||
|
||||
### ArchiMate 3.2
|
||||
|
||||
`Business_Actor`, `Application_Service`, `Technology_Node`, `Rel_Serving` / `Rel_Realization` / `Rel_Assignment`, …
|
||||
Подробно: [archimate.md](archimate.md).
|
||||
|
||||
Полные шпаргалки — поля `docs` и `ai-prompt` в JSON; в GUI: **Шаблоны UML** и кнопки схем в **Справка → Промпт для ИИ**.
|
||||
|
||||
## IDEF0 (FIPS 183)
|
||||
|
||||
Оградка `` ```idef0 `` (то же: `` ```uml-idef0 ``). Текст — не XML: шапка формы, боксы, стрелки ICOM (Input слева, Control сверху, Output справа, Mechanism снизу). Контекст A-0 — один бокс; декомпозиция — несколько боксов «лесенкой» и связи между ними. Рендер локальный (Pillow), без Java и Kroki.
|
||||
|
||||
````markdown
|
||||
%a0 Контекстная диаграмма A-0
|
||||
|
||||
```idef0
|
||||
title Распорядиться товаром
|
||||
node A-0
|
||||
number 1
|
||||
author Кинзябулатов Рамиль
|
||||
project Разработка универсальной модели торгового предприятия
|
||||
date 08.02.2022
|
||||
rev 31.03.2022
|
||||
status PUBLICATION
|
||||
context TOP
|
||||
|
||||
[A0] Распорядиться товаром
|
||||
<- Спрос
|
||||
^ Нормативная документация
|
||||
-> Товар
|
||||
v Персонал
|
||||
```
|
||||
````
|
||||
|
||||
Шапка/подвал NIST-формы (необязательные поля): `title`, `node`, `number`, `author`, `project`, `date`, `rev`, `status` (`WORKING` / `DRAFT` / `RECOMMENDED` / `PUBLICATION`), `context` (для A-0 — `TOP`), `used_at`, `reader`, `reader_date`, `notes`, `purpose`, `viewpoint`, `page`, `form` (`kit` по умолчанию, `plain` — без рамки).
|
||||
|
||||
Бокс: `[A0] Имя` или `box A0 Имя`. Стрелки к последнему боксу: `<-` / `in` вход, `^` / `control` управление, `->` / `out` выход, `v` / `mech` механизм. Явно: `A0 <- Спрос`.
|
||||
|
||||
Декомпозиция A0 (3 бокса, развилка, O→C, ICOM на рамке):
|
||||
|
||||
````markdown
|
||||
%a0 Декомпозиция A0
|
||||
|
||||
```idef0
|
||||
title Распорядиться товаром
|
||||
node A0
|
||||
number 2
|
||||
author Кинзябулатов Рамиль
|
||||
project Разработка универсальной модели торгового предприятия
|
||||
date 08.02.2022
|
||||
rev 31.03.2022
|
||||
status PUBLICATION
|
||||
context A-0
|
||||
purpose Обеспечить наличие товара и его отпуск покупателю
|
||||
viewpoint Руководитель торгового предприятия
|
||||
|
||||
[A1] Принять и оценить спрос
|
||||
[A2] Закупить и хранить
|
||||
[A3] Отгрузить товар
|
||||
|
||||
A1 <- Спрос [I1]
|
||||
A2 <- Предложения поставщиков [I2]
|
||||
A1, A2, A3 ^ Нормативная документация [C1]
|
||||
A1 ^ Ассортиментная политика [C2]
|
||||
A3 ^ Условия поставки [C3]
|
||||
A1 -> A2 : план закупок
|
||||
A1 -> A2.C : график поставок
|
||||
A2 -> A3 : товар к отгрузке
|
||||
A3 -> Товар [O1]
|
||||
A3 -> Акт отгрузки [O2]
|
||||
A1, A2, A3 v Персонал [M1]
|
||||
A2 v Склад [M2]
|
||||
```
|
||||
````
|
||||
|
||||
Подписи к стрелкам — существительные рядом со стволом, не на линии; если связь не очевидна, рисуется squiggle (FIPS 183). Длинные фразы переносятся на 2–3 строки.
|
||||
|
||||
Короткий каркас:
|
||||
|
||||
```
|
||||
[A1] Принять заказ
|
||||
[A2] Отгрузить
|
||||
A1 <- Заявка [I1]
|
||||
A1 ^ Правила [C1]
|
||||
A1 -> A2 : заказ
|
||||
A1 -> A2, A3 : данные
|
||||
A1, A2 -> A3 : сводка
|
||||
A2 -> Товар [O1]
|
||||
A2 v Склад [M1]
|
||||
```
|
||||
|
||||
Туннель (скобки IDEF0): `(in Секрет` — на границе листа, `in) Локальный` — у бокса. Вызов другой модели: `call ИмяМодели`. Сторона порта: `A1.O -> A2.C : условие`.
|
||||
|
||||
## DFD (data-flow-diagram / SA/RT)
|
||||
|
||||
Оградка `` ```dfd `` (то же: `` ```uml-dfd ``, `` ```data-flow-diagram ``, `` ```yourdon ``). Синтаксис — пакет [pbauermeister/dfd](https://github.com/pbauermeister/dfd) (ставится с md2gost, Python 3.11+). Рендер: Graphviz (`dot` / `neato`) или Kroki (`graphviz`), не Pillow.
|
||||
|
||||
Порядок поиска Graphviz (как у `plantuml.jar`):
|
||||
|
||||
1. Вшитый `md2gost/vendor/graphviz` (exe-сборка / `python scripts/fetch_graphviz.py`)
|
||||
2. Кэш `%LOCALAPPDATA%\md2gost\graphviz` (GUI: **Настройки → Диаграммы → «Скачать Graphviz»**)
|
||||
3. `dot` / `neato` в `PATH`
|
||||
4. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
|
||||
|
||||
````markdown
|
||||
%dfd0 Контекстная DFD
|
||||
|
||||
```dfd
|
||||
style context
|
||||
|
||||
entity Client Клиент
|
||||
entity Warehouse Склад
|
||||
process System Система учёта заявок
|
||||
|
||||
Client --> System заявка
|
||||
System --> Client статус
|
||||
System --> Warehouse накладная
|
||||
```
|
||||
````
|
||||
|
||||
Ключевые слова узлов: `process`, `entity`, `store`, `control`, `channel`. Потоки: `-->`, `->>`, `<->`, сигналы `::>` / `<::`. Контекст: `style context`. Полный синтаксис — [документация Upstream](https://github.com/pbauermeister/dfd/blob/main/doc/README.md).
|
||||
|
||||
Декомпозиция (уровень 0):
|
||||
|
||||
````markdown
|
||||
%dfd1 DFD уровня 0
|
||||
|
||||
```dfd
|
||||
entity Client Клиент
|
||||
entity Warehouse Склад
|
||||
process Accept Принять заявку
|
||||
process Register Зарегистрировать
|
||||
process Ship Сформировать накладную
|
||||
store Orders Заявки
|
||||
|
||||
Client --> Accept заявка
|
||||
Accept --> Orders новая заявка
|
||||
Orders --> Register данные заявки
|
||||
Register --> Ship подтверждённая заявка
|
||||
Ship --> Warehouse накладная
|
||||
Ship --> Client уведомление
|
||||
```
|
||||
````
|
||||
|
||||
Зависимости: `data-flow-diagram` идёт с md2gost (Python ≥3.11). Без Graphviz офлайн DFD не нарисуется — либо кнопка «Скачать Graphviz» / `scripts/fetch_graphviz.py`, либо `--diagram-fallback remote` → kroki.io.
|
||||
|
||||
## Файл схем: поля
|
||||
|
||||
```json
|
||||
{
|
||||
"c4": {
|
||||
"title": "C4 Container",
|
||||
"docs": "…макросы…",
|
||||
"ai-prompt": "…инструкция для ИИ…",
|
||||
"includes": ["https://…/C4_Container.puml"],
|
||||
"prefix": "@startuml\n",
|
||||
"postfix": "\nLAYOUT_WITH_LEGEND()\n@enduml"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Назначение |
|
||||
|------|------------|
|
||||
| `title` | Подпись в GUI |
|
||||
| `docs` | Шпаргалка макросов |
|
||||
| `ai-prompt` | Фрагмент для промпта ИИ |
|
||||
| `includes` | Локальные `.puml` или URL |
|
||||
| `prefix` / `postfix` | Обёртка вокруг тела |
|
||||
| `theme` | опционально `!theme …` |
|
||||
|
||||
id схемы: латиница, цифры, `_`; начинается с буквы.
|
||||
|
||||
## Слои загрузки (позже побеждает)
|
||||
|
||||
1. Встроенный шаблон `md2gost/diagrams/schemes.json`
|
||||
2. Пользовательский файл рядом с приложением (`md2gost.schemes.json`)
|
||||
3. `md2gost.schemes.json` рядом с `.md`
|
||||
4. Явный путь `--schemes path.json`
|
||||
|
||||
## Рендер
|
||||
|
||||
**UML / схемы:**
|
||||
|
||||
1. Java + `plantuml.jar` (вшитый / `%LOCALAPPDATA%\md2gost\` / `--plantuml-jar`)
|
||||
2. Локальный Kroki (`KROKI_URL` / `--kroki-url`, по умолчанию `http://localhost:8000`)
|
||||
3. `https://kroki.io` при `--diagram-fallback remote` (по умолчанию)
|
||||
|
||||
**Mermaid:**
|
||||
|
||||
1. Системный Chrome / Edge (через Playwright, ничего не качаем)
|
||||
2. Playwright Chromium в `%LOCALAPPDATA%\md2gost\ms-playwright` — кнопка «Скачать headless Chromium» или `md2gost --install-chromium`
|
||||
3. QuickJS + mermaid.js (`mermaidx`) — офлайн без браузера
|
||||
4. Локальный Kroki → `https://kroki.io` (если `--diagram-fallback remote`)
|
||||
|
||||
**IDEF0:** сразу Pillow (PNG, при `--diagram-format svg` ещё SVG). Java/Kroki не используются.
|
||||
|
||||
**DFD:**
|
||||
|
||||
1. [data-flow-diagram](https://github.com/pbauermeister/dfd) компилирует DSL → DOT
|
||||
2. Вшитый / кэш / PATH Graphviz (`dot`, для `style context` — `neato`)
|
||||
3. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
|
||||
|
||||
Формат: `--diagram-format png` (по умолчанию) или `svg`. Масштаб растра PlantUML / локального Mermaid / IDEF0: `--diagram-scale` (по умолчанию 2 — качество, размер на странице как при 1).
|
||||
|
||||
Кэш картинок: `{каталог_md}/.md2gost-cache/`.
|
||||
Кэш includes: `md2gost.include-cache.json` + папка `include-cache/` (сброс в GUI: Настройки → Диаграммы / Шаблоны UML).
|
||||
|
||||
## Свои схемы
|
||||
|
||||
1. Меню **Шаблоны UML** — добавить / править / сохранить.
|
||||
2. Или править `md2gost.schemes.json` вручную («Открыть JSON»).
|
||||
3. CLI: `--schemes путь.json`.
|
||||
|
||||
Чтобы правка встроенной схемы не сбрасывалась при обновлении приложения, оставьте пустой `author` (GUI делает это сама при сохранении изменённой встроенной схемы).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Кастомные стили (JSON)
|
||||
|
||||
Опциональный оверлей оформления поверх пресета `--type` (`mirea` или `pis_custom`).
|
||||
Неуказанные поля остаются у пресета. Стили пишет конвертер в DOCX сам — вручную в шаблон добавлять не нужно.
|
||||
|
||||
## Как подключить
|
||||
|
||||
Позже побеждает (deep-merge только указанных ключей):
|
||||
|
||||
1. Встроенный пресет типа документа
|
||||
2. `md2gost.styles.json` рядом с `.md` (если есть)
|
||||
3. `--styles путь.json` или путь в GUI: **Настройки → Файлы → Стили JSON**
|
||||
|
||||
Файл рядом с exe при первом запуске **не создаётся**.
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --type practice --styles examples/md2gost.styles.json
|
||||
```
|
||||
|
||||
Пример: [`examples/md2gost.styles.json`](../examples/md2gost.styles.json).
|
||||
|
||||
## Схема
|
||||
|
||||
Два блока. Неизвестный ключ или имя стиля — ошибка.
|
||||
|
||||
```json
|
||||
{
|
||||
"page": {
|
||||
"left_mm": 30,
|
||||
"right_mm": 10,
|
||||
"top_mm": 20,
|
||||
"bottom_mm": 20
|
||||
},
|
||||
"styles": {
|
||||
"Heading 1": {
|
||||
"alignment": "center",
|
||||
"size_pt": 14,
|
||||
"all_caps": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `page`
|
||||
|
||||
| Ключ | Единица | Смысл |
|
||||
|------|---------|--------|
|
||||
| `left_mm` / `right_mm` / `top_mm` / `bottom_mm` | мм | Поля страницы |
|
||||
|
||||
### Имена стилей
|
||||
|
||||
`Normal`, `Heading 1`, `Heading 2`, `Heading 3`, `Caption Figure`, `Caption Table`, `Название таблицы`, `Caption Listing`, `Caption`, `Code`, `Table Text`, `Bibliography`, `Bibliography Heading`, `toc 1`, `toc 2`, `toc 3`, `Footer`, `Hyperlink`, `FollowedHyperlink`, `Space After Table`.
|
||||
|
||||
### Поля стиля
|
||||
|
||||
| Ключ | Тип / значения |
|
||||
|------|----------------|
|
||||
| `font_name` | строка |
|
||||
| `size_pt` | число (pt) |
|
||||
| `bold`, `italic`, `all_caps`, `underline` | bool |
|
||||
| `alignment` | `left` \| `center` \| `justify` |
|
||||
| `first_line_indent_cm`, `left_indent_cm`, `right_indent_cm` | см |
|
||||
| `space_before_mm`, `space_after_mm` | мм |
|
||||
| `line_spacing` | `1.0` \| `1.5` |
|
||||
| `page_break_before`, `keep_with_next`, `widow_control` | bool |
|
||||
|
||||
Не в JSON: табы оглавления (считаются от ширины полосы), отступы списков, формулы из шаблона, цвет (всегда чёрный).
|
||||
@@ -0,0 +1,187 @@
|
||||
# Автотитульник
|
||||
|
||||
md2gost умеет **сам собрать титульный лист** из вшитого шаблона `TitleTemplate.docx`, подставив институт, кафедру, дисциплину, преподавателя, номер работы, ФИО и группу.
|
||||
|
||||
Готовый чужой DOCX титула (`--title` / «Настройки → Файлы → Титульный лист») по-прежнему работает и **полностью отключает** автогенерацию.
|
||||
|
||||
---
|
||||
|
||||
## Когда титул генерируется
|
||||
|
||||
Автотитул **по умолчанию выключен**. Включается явно:
|
||||
|
||||
- CLI: `--auto-title`
|
||||
- GUI: галочка **«Автотитул»**
|
||||
|
||||
При включённом автотитуле он собирается, если одновременно:
|
||||
|
||||
1. **Не задан** явный путь к титульному DOCX (`--title` пуст, в GUI поле «Титульный лист» пустое).
|
||||
2. Рядом с `.md` есть файл **`info_conv.yaml`** *или* в начале Markdown есть блок **\`\`\`title**.
|
||||
|
||||
Если автотитул выключен или нет ни yaml, ни блока `title` — титул не вставляется (готовый DOCX по `--title` по-прежнему можно передать отдельно).
|
||||
|
||||
ФИО и группа **обязательны** для генерации: из профиля GUI / `md2gost.user.json` или из `--student` / `--group`. Без них конвертация с `--auto-title` завершится ошибкой с подсказкой.
|
||||
|
||||
---
|
||||
|
||||
## Быстрый сценарий (GUI)
|
||||
|
||||
1. Первый запуск → окно **«Данные студента»**: введите **ФИО** и **группу**, сохраните.
|
||||
Позже: **Настройки → Студент…**.
|
||||
2. В папке с отчётом создайте `info_conv.yaml` (см. ниже).
|
||||
3. В начале `.md` при необходимости добавьте блок \`\`\`title с номером работы и годом.
|
||||
4. Включите галочку **«Автотитул»** на главном окне.
|
||||
5. **Не** указывайте готовый титул в «Настройки → Файлы», если хотите автогенерацию.
|
||||
6. Конвертируйте как обычно — перед телом отчёта появится титул.
|
||||
|
||||
Профиль студента хранится в `md2gost.user.json` рядом с приложением (рядом с exe или в текущей папке при `python -m md2gost`) — тот же каталог, что и `md2gost.schemes.json`. В том же файле блок `metadata` — свойства DOCX (автор/название/примечание); GUI: **Настройки → Метаданные…**, CLI: `--doc-*`. Это не поля титульного листа.
|
||||
|
||||
---
|
||||
|
||||
## `info_conv.yaml` (данные курса / кафедры)
|
||||
|
||||
Файл кладётся **в тот же каталог**, что и конвертируемый `.md`. Имя строго: `info_conv.yaml`.
|
||||
|
||||
Пример:
|
||||
|
||||
```yaml
|
||||
Институт: Институт информационных технологий (ИИТ)
|
||||
Кафедра: Кафедра цифровой трансформации (ЦТ)
|
||||
Дисциплина: Проектирование архитектуры цифровой организации
|
||||
Преподаватель: Иванов Иван Иванович
|
||||
Должность: старший преподаватель, кафедра ЦТ
|
||||
```
|
||||
|
||||
Формат — плоский список `ключ: значение` (UTF-8). Вложенный YAML и списки не нужны. Строки с `#` в начале — комментарии; хвост ` # …` в значении отрезается.
|
||||
|
||||
Один `info_conv.yaml` удобно держать на весь семестр/дисциплину и не копировать институт с кафедрой в каждый отчёт.
|
||||
|
||||
---
|
||||
|
||||
## Блок `title` в Markdown
|
||||
|
||||
Опциональный блок в **начале** файла (до `# *СОДЕРЖАНИЕ`), ограда языка `title`. Перед конвертацией он **вырезается** из тела отчёта и не попадает в DOCX как код.
|
||||
|
||||
Типичное использование — номер работы и год:
|
||||
|
||||
````markdown
|
||||
```title
|
||||
number: 1
|
||||
year: 2026
|
||||
```
|
||||
|
||||
# *СОДЕРЖАНИЕ
|
||||
|
||||
[TOC]
|
||||
…
|
||||
````
|
||||
|
||||
Можно переопределить любые академические поля прямо в блоке (они сильнее yaml):
|
||||
|
||||
````markdown
|
||||
```title
|
||||
number: 3
|
||||
year: 2026
|
||||
discipline: Другая формулировка дисциплины
|
||||
teacher: Петров П. П.
|
||||
```
|
||||
````
|
||||
|
||||
Английские ключи тоже принимаются: `institute`, `department`, `discipline`, `teacher`, `teacher_position`, `number`, `year`.
|
||||
|
||||
---
|
||||
|
||||
## Поля: куда попадают и откуда берутся
|
||||
|
||||
| Поле на титуле | Ключи (любой из вариантов) | Источник |
|
||||
|----------------|----------------------------|----------|
|
||||
| Институт | `Институт`, `Institute`, `institute`; опечатка `Инститиут` | yaml / \`\`\`title |
|
||||
| Кафедра | `Кафедра`, `Department`, `department` | yaml / \`\`\`title |
|
||||
| Дисциплина (в «…») | `Дисциплина`, `Discipline`, `discipline` | yaml / \`\`\`title |
|
||||
| № практической работы | `number`, `номер`, `№`, `n` | \`\`\`title (иначе `1`) |
|
||||
| Группа («студент группы …») | — | профиль GUI / `--group` |
|
||||
| ФИО студента | — | профиль GUI / `--student` |
|
||||
| Должность преподавателя | `Должность`, `должность преподавателя`, `teacher_position` | yaml / \`\`\`title |
|
||||
| ФИО преподавателя | `Преподаватель`, `Преподователь`, `Teacher`, `teacher` | yaml / \`\`\`title |
|
||||
| Год («Москва … г.») | `year`, `год` | \`\`\`title (иначе текущий календарный год) |
|
||||
|
||||
Строка «ОТЧЁТ ПО ПРАКТИЧЕСКОЙ РАБОТЕ» и шапка МИРЭА в шаблоне **не** заполняются из переменных — это фиксированный текст шаблона.
|
||||
|
||||
ФИО/`группа` из yaml или \`\`\`title **игнорируются** (даже если написать `Студент:` / `Группа:`) — студент всегда из профиля или CLI.
|
||||
|
||||
---
|
||||
|
||||
## Приоритет слияния
|
||||
|
||||
```
|
||||
Академические поля: info_conv.yaml < ```title (блок сильнее)
|
||||
Студент / группа: md2gost.user.json < --student / --group
|
||||
Явный титул DOCX: --title / путь в GUI → генератор не вызывается
|
||||
```
|
||||
|
||||
`year` без значения → текущий год. `number` без значения → `1`.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# автотитул выкл. по умолчанию — только тело отчёта
|
||||
python -m md2gost report.md -o report.docx
|
||||
|
||||
# включить автотитул (yaml + профиль из md2gost.user.json)
|
||||
python -m md2gost report.md -o report.docx --auto-title
|
||||
|
||||
# ФИО и группа на один прогон
|
||||
python -m md2gost report.md --auto-title --student "Тарасов Игорь Алексеевич" --group ИНБО-31-23
|
||||
|
||||
# свой готовый титул — автогенерация не используется
|
||||
python -m md2gost report.md --title title.docx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Пример структуры папки
|
||||
|
||||
```
|
||||
Отчет_практики/
|
||||
info_conv.yaml ← институт, кафедра, дисциплина, препод
|
||||
report_pr1.md ← в начале ```title с number/year
|
||||
figures/
|
||||
…
|
||||
```
|
||||
|
||||
`report_pr1.md`:
|
||||
|
||||
````markdown
|
||||
```title
|
||||
number: 1
|
||||
year: 2026
|
||||
```
|
||||
|
||||
# *СОДЕРЖАНИЕ
|
||||
|
||||
[TOC]
|
||||
|
||||
# *ВВЕДЕНИЕ
|
||||
…
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## Частые вопросы
|
||||
|
||||
**Титул не появился.**
|
||||
Проверьте: включён ли `--auto-title` / галочка «Автотитул»; нет ли пути в «Титульный лист» / `--title`; есть ли `info_conv.yaml` или \`\`\`title; заполнены ли ФИО и группа.
|
||||
|
||||
**Не тот номер работы.**
|
||||
Задайте `number:` в \`\`\`title — из yaml номер не читается (только из блока или дефолт `1`).
|
||||
|
||||
**Нужен другой шаблон вёрстки.**
|
||||
Сейчас слоты привязаны к bundled `TitleTemplate.docx`. Чужой макет — собирайте титул вручную и передавайте через `--title`.
|
||||
|
||||
**Задание (бланк).**
|
||||
Автотитул не заменяет бланк задания: по-прежнему `--assignment` / «Бланк задания» в GUI.
|
||||
|
||||
**Где лежит профиль.**
|
||||
`md2gost.user.json` рядом с приложением (см. каталог схем). Не коммитьте его с чужими ФИО в общие репозитории отчётов — это локальные данные студента.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Типы документов
|
||||
|
||||
Задаётся флагом `--type` / выбором в GUI. Профили: [`md2gost/profiles.py`](../md2gost/profiles.py).
|
||||
|
||||
## Сводка
|
||||
|
||||
| Тип | Стили | Нумерация объектов | Источники | Структура |
|
||||
|-----|--------|-------------------|-----------|-----------|
|
||||
| `practice` | МИРЭА | по разделам (1.1, 2.1) | 5–7, ≤5 лет | Введение, разделы, заключение, список, приложения |
|
||||
| `coursework` | МИРЭА | по разделам | 5–7, ≤5 лет | то же |
|
||||
| `vkr` | МИРЭА | по разделам | ≥10 в **каждом** разделе списка, ≤5 лет | + приложение «Графический материал»; ссылки `[1.5]` |
|
||||
| `PIS_custom` | ПИС | сквозная (1, 2, 3) | не требуются | H1 = практические работы; без введения/заключения по умолчанию |
|
||||
| `APID_coursework` | МИРЭА | по разделам | 7–20, ≤5 лет | обязательные главы/пункты АПИД (см. ниже) |
|
||||
|
||||
## coursework / practice
|
||||
|
||||
Спецразделы с `*`, нумерованная основная часть. В DOCX: H1 слева с отступом (кроме СОДЕРЖАНИЕ / СПИСОК — по центру).
|
||||
|
||||
Проверка: `--check` / `--strict`.
|
||||
|
||||
## vkr
|
||||
|
||||
- Список источников **с разделами**; в каждом ≥10 записей.
|
||||
- Ссылки вида `[1.5]`, при цитировании `[2.18, c. 21-25]`.
|
||||
- Обязательное приложение с текстом «Графический материал» в названии.
|
||||
|
||||
## PIS_custom
|
||||
|
||||
Итоговый отчёт по практическим работам.
|
||||
|
||||
```markdown
|
||||
# *СОДЕРЖАНИЕ
|
||||
[TOC]
|
||||
|
||||
# Практическая работа №1. Название работы
|
||||
|
||||
## Цель работы
|
||||
## Ход работы
|
||||
## Выводы
|
||||
|
||||
# Практическая работа №2. …
|
||||
```
|
||||
|
||||
- H1 **без** `*` (кроме СОДЕРЖАНИЕ): в DOCX — ПРОПИСНЫМИ по центру.
|
||||
- H2 с абзацным отступом.
|
||||
- Титул («Отчёт по практическим работам …») — отдельный DOCX: `--title title.docx`.
|
||||
|
||||
Пример: [`examples/pis_custom.md`](../examples/pis_custom.md).
|
||||
Промпт: [`prompts/generate-pis-custom-report.md`](../prompts/generate-pis-custom-report.md).
|
||||
|
||||
## APID_coursework
|
||||
|
||||
Курсовая «Архитектура приложений и данных» (методичка Аншиной/Лагуновой). Оформление как у `coursework`, плюс проверка заголовков:
|
||||
|
||||
1. Теоретические аспекты …
|
||||
2. Прикладные аспекты …
|
||||
- 2.1 Описание проекта …
|
||||
- 2.2 Описание роли …
|
||||
- 2.3 Описание архитектуры …
|
||||
- 2.4 Варианты развития архитектуры …
|
||||
|
||||
Источники: 7–20.
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --type APID_coursework --check --title title.docx --assignment assignment.docx
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# Импорт Word → Markdown (word2md)
|
||||
|
||||
Обратный пайплайн: **DOCX → Markdown** в диалекте md2gost. Удобно, если отчёт уже набран в Word (или собран md2gost раньше), а дальше править удобнее в `.md`.
|
||||
|
||||
Это **эвристический** импорт, не lossless round-trip: исходник UML/Mermaid, `%id` из исходного md и язык fence у листинга из DOCX не восстанавливаются.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
python -m word2md report.docx -o report.md
|
||||
word2md report.docx
|
||||
python -m md2gost report.docx -o report.md # тот же импорт через md2gost
|
||||
```
|
||||
|
||||
В GUI: перетащите `.docx` или кнопка **«Импорт DOCX→MD»**.
|
||||
|
||||
Картинки пишутся в каталог `<stem>_media/` рядом с `.md` (или `--media-dir`).
|
||||
|
||||
| Флаг | Смысл |
|
||||
|------|--------|
|
||||
| `-o` / `--output` | путь к `.md` |
|
||||
| `--media-dir` | каталог картинок |
|
||||
| `--keep-toc-pages` | не выкидывать строки содержания Word (по умолчанию только `[TOC]`) |
|
||||
| `--pagebreaks` | писать `---` на разрывах страниц |
|
||||
|
||||
## Что распознаётся
|
||||
|
||||
- заголовки `Heading N` / спецразделы → `# *ВВЕДЕНИЕ`, `# 1 …`
|
||||
- содержание → `# *СОДЕРЖАНИЕ` + `[TOC]` (поле TOC и строки `toc N` отбрасываются)
|
||||
- подписи `Рисунок N — …`, `Таблица N`, `Листинг N` (+ «Продолжение…» склеивается)
|
||||
- таблицы с merge → `^` / `>`
|
||||
- листинги стиля `Code`
|
||||
- формулы (таблица Formula / OMML) → `$$…$$`
|
||||
- библиография `[1]: …`, ссылки `[1]` в тексте
|
||||
- второй проход: `Рисунок 1.1` → `@Рисунок:fig1_1` (если номер известен по подписи)
|
||||
|
||||
Титул и бланк задания до первого H1/спецраздела пропускаются. Колонтитулы не импортируются.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Нет восстановления PlantUML / Mermaid (только PNG/JPEG из файла)
|
||||
- OMML → формула — best-effort, не идеальный LaTeX
|
||||
- Надписи, SmartArt, сноски, плавающие рисунки — пропуск с записью в лог
|
||||
- Титул, свёрстанный большой таблицей, может попасть в поток, если walker уже «стартовал» — обычно титул до первого Heading пропускается
|
||||
|
||||
После импорта имеет смысл прогнать `python -m md2gost report.md --check`.
|
||||
@@ -279,7 +279,11 @@ System --> Warehouse накладная
|
||||
- Маркированный список: пункты со строчной буквы, в конце `;`, у последнего `.`.
|
||||
- Нумерованный список: пункты с прописной буквы, в конце `.`.
|
||||
- Знаки `+`, `−`, `=`, `>`, `<` в тексте без чисел пишутся словами.
|
||||
- Общепринятые сокращения оформляются полностью при первом упоминании с расшифровкой аббревиатуры в скобках.
|
||||
- Общепринятые сокращения оформляются полностью при первом упоминании с расшифровкой аббревиатуры в скобках. Полное официальное наименование организации — до сокращения.
|
||||
- Несколько источников на одно утверждение группируются: `[1, 2]`, не `[1], [2]`.
|
||||
- Реквизиты юридического лица излагаются связным текстом, а не таблицей «Реквизит / Значение».
|
||||
- Рисунок, нечитаемый при масштабе 100 % в Word, считается отсутствующим: крупный кегль, `+landscape`, при необходимости PNG высокого разрешения.
|
||||
- На иерархической оргсхеме из одного узла выходит одна связь с последующей развязкой; у каждого отдела — руководитель и операционные специалисты.
|
||||
|
||||
# 13. Запрещено
|
||||
|
||||
@@ -231,7 +231,11 @@ def load_factor(requests: int, capacity: int) -> float:
|
||||
- Десятичные дроби через запятую: 20,5.
|
||||
- Маркированный список: со строчной буквы, в конце `;`, у последнего `.`.
|
||||
- Нумерованный список: с прописной буквы, в конце `.`.
|
||||
- Аббревиатура при первом упоминании расшифровывается.
|
||||
- Аббревиатура при первом упоминании расшифровывается; полное официальное наименование — раньше сокращения.
|
||||
- Несколько источников на одно утверждение: `[1, 2]`, не `[1], [2]`.
|
||||
- Реквизиты юридического лица — связным текстом, не таблицей «Реквизит / Значение».
|
||||
- Рисунок должен читаться при масштабе 100 % в Word. Иерархическая оргсхема: одна связь из узла и развязка; у отдела есть руководитель и специалисты.
|
||||
- Не дроби пункт задания на лишние `##`, если руководитель это запретил. Материал узкого места процесса не помещай в характеристику объекта.
|
||||
- Знаки `=`, `>`, `<`, `+` в тексте без формул пишутся словами.
|
||||
|
||||
# 14. Запрещено
|
||||
@@ -0,0 +1,122 @@
|
||||
Ты — составитель нарастающего отчёта по практическим работам дисциплины проектного цикла (ПАЦО и аналоги). Пользователь даёт объект, процесс, факты и замечания руководителя — ты возвращаешь один готовый `.md` в диалекте **md2gost**, собираемый ключом `--type practice`.
|
||||
|
||||
# 1. Контракт вывода
|
||||
|
||||
1. Ответ = **только текст документа** в Markdown, с первого символа.
|
||||
2. Никакой внешней оградки, преамбул, пояснений и вопросов.
|
||||
3. Язык — русский, **собственно-научный подстиль**. Не публицистика, не тезисы, не отчёт модели о сдаче пунктов.
|
||||
4. Данных не хватает — не спрашивай, достраивай правдоподобно по фактам пользователя. Числа, ИНН, выручку и цитаты не выдумывай, если пользователь их не дал.
|
||||
5. Титульный лист не пиши.
|
||||
|
||||
# 2. Каркас
|
||||
|
||||
~~~markdown
|
||||
# *СОДЕРЖАНИЕ
|
||||
|
||||
[TOC]
|
||||
|
||||
# 1 Характеристика объекта исследования
|
||||
|
||||
Связный текст пункта без внутренних ##.
|
||||
|
||||
# 2 Характеристика предмета исследования
|
||||
|
||||
Связный текст пункта без внутренних ##.
|
||||
|
||||
# *ЗАКЛЮЧЕНИЕ
|
||||
|
||||
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
~~~
|
||||
|
||||
Правила заголовков:
|
||||
|
||||
- Пункт задания = нумерованный H1: `# 1 …`, `# 2 …`. Точки в конце нет.
|
||||
- Слова «Практическая работа», «Практическая работа №» в заголовке **не писать**, если пользователь или руководитель это запретили.
|
||||
- Внутренние `##` / `###` не создавать: юридический статус, оргмодель, карта, макросреда, термины, границы — это абзацы одного пункта, а не подразделы.
|
||||
- Спецразделы только со звёздочкой и ПРОПИСНЫМИ: `# *СОДЕРЖАНИЕ`, `# *ЗАКЛЮЧЕНИЕ`, `# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ`.
|
||||
- `[TOC]` — прописными, сразу под содержанием.
|
||||
- Введение не обязательно, пока его не потребовали. Ссылок `[n]` в заключении нет.
|
||||
|
||||
Если пользователь явно просит профиль `PIS_custom` с «Цель / Ход / Выводы» — тогда используй промпт `report-pis.md`, а не этот.
|
||||
|
||||
# 3. Стиль
|
||||
|
||||
Связные абзацы. Факт → уточнение → следствие без риторики.
|
||||
|
||||
Запрещены обороты вроде «давит на маржу», «тянут к», «платформа усложняется, зато», «экосистема растёт», «общество тянет выручку», «нет смысла в отделе», рубленые списки «Экономические факторы — … Социальные — …».
|
||||
|
||||
Пиши: «характеризуется», «обусловлено», «составляет», «применяется», «приведено на».
|
||||
|
||||
Аббревиатура при первом упоминании: полное наименование, затем скобка с сокращением и при необходимости «далее —». Пример: акционерное общество «Производственная фирма «СКБ Контур» (далее — АО «ПФ «СКБ Контур») является акционерным обществом частной формы собственности…
|
||||
|
||||
# 4. Что в каком пункте
|
||||
|
||||
**Пункт «характеристика объекта».** Правовой статус и значимые реквизиты связным текстом (не таблица «Реквизит / Значение»). Виды деятельности. Специфика. Организационная модель и карта процессов верхнего уровня. Макро- и микросреда, динамика показателей. Масштаб занятости как характеристика объекта допустим.
|
||||
|
||||
**Нельзя в характеристике объекта:** узкое место процесса, «рост заявок упирается в ручную проверку», параметры оптимизации, декомпозиция технологии выполнения.
|
||||
|
||||
**Пункт «характеристика предмета».** Наименование процесса, содержание, термины, место в типовой процессной структуре, границы, структура списком, исполнители, нормативное регулирование.
|
||||
|
||||
В последующих пунктах студент (и ты) до выдачи файла сам критически сверяет фрагмент проектного решения: полнота артефактов, читаемость рисунков, иерархия оргсхемы, стиль, попадание фактов в свой пункт, группировка ссылок. В текст отчёта фразу «проведён критический анализ» не вставляй — исправь недочёты.
|
||||
|
||||
# 5. Объекты
|
||||
|
||||
Метка `%id`, ссылка `@Тип:id`. Номера пишет конвертер.
|
||||
|
||||
| Объект | Метка | Ссылка |
|
||||
|--------|-------|--------|
|
||||
| Рисунок-файл | `` | `@Рисунок:id` |
|
||||
| Диаграмма | `%id Подпись` перед fenced-блоком | `@Рисунок:id` |
|
||||
| Таблица | `%id Подпись` перед таблицей | `@Таблица:id` |
|
||||
|
||||
Предложение со ссылкой стоит перед объектом и заканчивается точкой. Слова «Рисунок», «Таблица» полностью. «рис.» и «табл.» запрещены.
|
||||
|
||||
Таблица реквизитов юридического лица запрещена: перенести в абзац. Таблицы динамики и классификаторов допустимы.
|
||||
|
||||
# 6. Рисунки: читаемость и иерархия
|
||||
|
||||
Рисунок, который нельзя прочитать при масштабе 100 % в Word, отсутствует.
|
||||
|
||||
- Широкая схема — `+landscape`.
|
||||
- Подписи на листе не мельче 10–11 пт.
|
||||
- Если PlantUML или Mermaid после сборки даёт мелкую схему — сдавай PNG высокого разрешения, а не исходник нотации.
|
||||
|
||||
Организационная модель:
|
||||
|
||||
- из одного узла выходит **одна** связь, затем развязка (горизонтальная шина) к подчинённым;
|
||||
- направленные стрелки не используются;
|
||||
- у каждого показанного отдела есть руководитель и специалисты операционного уровня, а не один специалист и не пустой бокс;
|
||||
- детализация — в подразделении исполнителей процесса; прочие направления — руководитель + специалист;
|
||||
- должности обезличены, наименования из справочника должностей.
|
||||
|
||||
Карта процессов верхнего уровня: группы без стрелок последовательности; названия направлений, не клики.
|
||||
|
||||
# 7. Источники
|
||||
|
||||
В тексте: `[1]`, `[1, 2]`, `[2, 5]`. Нельзя `[1], [2]`.
|
||||
|
||||
Порядок списка = порядок первого упоминания. Во введении и заключении ссылок нет. Сноски `[^1]` запрещены.
|
||||
|
||||
# 8. Диаграммы
|
||||
|
||||
Конвертер рисует fenced-блоки. Для оргсхемы и карты предпочтителен заранее собранный читаемый PNG.
|
||||
|
||||
| Оградка | Тело |
|
||||
|---------|------|
|
||||
| ` ```uml ` | полный PlantUML с `@startuml` … `@enduml` |
|
||||
| ` ```mermaid ` | синтаксис Mermaid |
|
||||
| ` ```idef0 ` | DSL IDEF0 |
|
||||
| ` ```dfd ` | DSL потоков данных |
|
||||
|
||||
# 9. Самопроверка перед выдачей
|
||||
|
||||
1. Внешней оградки нет.
|
||||
2. Есть `# *СОДЕРЖАНИЕ`, `[TOC]`, нумерованные пункты без слов «Практическая работа», `# *ЗАКЛЮЧЕНИЕ`, `# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ`.
|
||||
3. Внутри пунктов нет лишних `##`.
|
||||
4. Первое упоминание каждого имени и аббревиатуры — с полной формой.
|
||||
5. Нет таблицы реквизитов; нет `[n], [m]`.
|
||||
6. Нет публицистических и тезисных формулировок.
|
||||
7. В пункте объекта нет узкого места процесса.
|
||||
8. Каждая метка имеет ссылку, каждая ссылка — метку.
|
||||
9. Оргсхема: развязка коннектора, руководитель + специалисты у каждого отдела, рисунок читается без увеличения.
|
||||
10. В заключении нет `[n]`.
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
- `# *СОДЕРЖАНИЕ` со звёздочкой и ПРОПИСНЫМИ, под ним отдельной строкой `[TOC]` — обязательно, иначе проверка выдаст ошибку.
|
||||
- Каждая практическая работа — **отдельный H1 без звёздочки**, строго вида `# Практическая работа №N. Название работы`. Номер и точка после номера обязательны. В DOCX заголовок станет ПРОПИСНЫМ по центру.
|
||||
- Этот вид заголовка нужен только профилю `--type PIS_custom`. Если руководитель запретил слова «Практическая работа» в названии пункта, не используй этот промпт: бери `report-paco.md` и `--type practice`.
|
||||
- Нельзя объединять несколько работ под одним H1 и нельзя разбивать одну работу на два H1.
|
||||
- Подразделы — `##` с прописной буквы, без точки в конце: `## Цель работы`, `## Ход работы`, `## Выводы`. При необходимости добавляются `## Постановка задачи`, `## Использованные средства`, `## Результаты`.
|
||||
- Вводного и заключительного разделов, а также списка источников в этом типе отчёта не требуется. Если пользователь просит их добавить — оформи как `# *ВВЕДЕНИЕ`, `# *ЗАКЛЮЧЕНИЕ`, `# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ` со звёздочкой.
|
||||
@@ -134,7 +135,8 @@ def is_valid(number: str) -> bool:
|
||||
| ` ```archimate ` | макросы ArchiMate 3.2 |
|
||||
| ` ```mermaid ` | синтаксис Mermaid |
|
||||
| ` ```idef0 ` | DSL IDEF0: `[A1] Имя`, `<-` вход, `^` управление, `->` выход, `v` механизм |
|
||||
| ` ```dfd ` | DSL потоков данных: `entity` / `process` / `store` и `-->` |
|
||||
| ` ```dfd ` | DSL потоков данных Йордона: `entity` / `process` / `store` и `-->` |
|
||||
| ` ```dfd-gs ` | то же DSL, нотация Гейна–Сарсона (скруглённый процесс, хранилище `D1`, штамп) |
|
||||
|
||||
Флаги подписи: `+landscape` — альбомная страница под широкую схему, `+listing` — вывести исходник схемы отдельным Листингом.
|
||||
|
||||
@@ -97,7 +97,25 @@
|
||||
5. Проверь, что во введении и заключении нет `[n]`.
|
||||
6. Выведи полный исправленный документ.
|
||||
|
||||
# 5. Самопроверка перед выдачей
|
||||
# 5. Замечания преподавателя, которые `--check` не ловит
|
||||
|
||||
Если пользователь приложил комментарий руководителя, правь и по нему, даже когда разметка валидна.
|
||||
|
||||
| Замечание | Что сделать |
|
||||
|-----------|-------------|
|
||||
| Убрать «Практическая работа» из названия пункта | `# Практическая работа 1 Тема` → `# 1 Тема` |
|
||||
| Лишние подразделы | схлопнуть `##` внутри пункта в связный текст |
|
||||
| Сначала полное наименование | первое упоминание организации и аббревиатур — с полной формой |
|
||||
| Таблица реквизитов | удалить, значимое перенести в абзац |
|
||||
| `[1], [2]` | заменить на `[1, 2]` |
|
||||
| Публицистика / тезисы | переписать в собственно-научный подстиль |
|
||||
| Рисунок не читается | укрупнить, `+landscape`, либо заменить на PNG; мелкий PlantUML/Mermaid не оставлять |
|
||||
| Оргсхема без развязки / без начальника отдела | одна связь из узла, затем шина; у каждого отдела руководитель и специалисты |
|
||||
| Факт не из своего пункта | перенести (узкое место процесса — не в характеристику объекта) |
|
||||
|
||||
В последующих практиках этот разбор выполняется до сдачи, без фразы «проведён критический анализ» в тексте.
|
||||
|
||||
# 6. Самопроверка перед выдачей
|
||||
|
||||
1. Выведен весь документ целиком, без внешней оградки и без комментариев.
|
||||
2. Авторский смысл сохранён; правки коснулись только нарушений.
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
Хорошо:
|
||||
|
||||
> Склад обрабатывает около 400 отгрузок в сутки при трёх зонах комплектации и одном участке приёмки. Основное ограничение — ручной ввод накладных: он даёт до 6 % расхождений в учёте и удлиняет отгрузку в среднем на 25 минут. Именно этот участок и определяет требования к системе.
|
||||
> Склад обрабатывает около 400 отгрузок в сутки при трёх зонах комплектации и одном участке приёмки. Ручной ввод накладных обусловливает до 6 % расхождений в учёте остатков и увеличивает длительность отгрузки в среднем на 25 минут. Указанный участок определяет требования к системе.
|
||||
|
||||
# 5. Таблицы
|
||||
|
||||
@@ -55,10 +55,10 @@
|
||||
# 6. Тон и язык
|
||||
|
||||
- Безличные конструкции нормальны: «выбрано», «применяется», «систематизированы». Без «я» и «мы».
|
||||
- Ссылки на источники — по делу, а не в каждом абзаце. Методички «как писать работу» в содержательную часть не тащи.
|
||||
- Допустима лёгкая неровность: абзацы разной длины, не каждое предложение «отрабатывает ценность». Идеальная симметрия разделов и плотный ряд умных ярлыков подряд — признак машинного текста.
|
||||
- Конкретика вместо консультантского лоска. «Повышение эффективности процессов» → «сокращение времени согласования с двух дней до четырёх часов».
|
||||
- Термины вводятся один раз и дальше используются, а не переопределяются в каждом разделе.
|
||||
- Ссылки на источники — по делу, а не в каждом абзаце. Несколько источников на один факт писать как — `[1, 2]`, не `[1], [2]`.
|
||||
- Для отчёта по практикам обязателен **собственно-научный подстиль**: связный абзац, термины, без метафор. Запрещены «давит на маржу», «тянут к», «усложняется, зато», рубленые «Экономические факторы — …».
|
||||
- «По-человечески» ≠ публицистика. Конкретика («3,1 млн клиентов», «ИНН 6663003127») остаётся; антитезы и разговорные глаголы — нет.
|
||||
- Термины и аббревиатуры вводятся один раз с полной формой и дальше не переопределяются.
|
||||
|
||||
# 7. Переписывание типовых фраз
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
name: GhostEditor Desktop
|
||||
colors:
|
||||
surface: '#0b1326'
|
||||
surface-dim: '#0b1326'
|
||||
surface-bright: '#31394d'
|
||||
surface-container-lowest: '#060e20'
|
||||
surface-container-low: '#131b2e'
|
||||
surface-container: '#171f33'
|
||||
surface-container-high: '#222a3d'
|
||||
surface-container-highest: '#2d3449'
|
||||
on-surface: '#dae2fd'
|
||||
on-surface-variant: '#bdc8d1'
|
||||
inverse-surface: '#dae2fd'
|
||||
inverse-on-surface: '#283044'
|
||||
outline: '#87929a'
|
||||
outline-variant: '#3e484f'
|
||||
surface-tint: '#7bd0ff'
|
||||
primary: '#8ed5ff'
|
||||
on-primary: '#00354a'
|
||||
primary-container: '#38bdf8'
|
||||
on-primary-container: '#004965'
|
||||
inverse-primary: '#00668a'
|
||||
secondary: '#c0c1ff'
|
||||
on-secondary: '#1000a9'
|
||||
secondary-container: '#3131c0'
|
||||
on-secondary-container: '#b0b2ff'
|
||||
tertiary: '#56e5a9'
|
||||
on-tertiary: '#003824'
|
||||
tertiary-container: '#30c88f'
|
||||
on-tertiary-container: '#004e34'
|
||||
error: '#ffb4ab'
|
||||
on-error: '#690005'
|
||||
error-container: '#93000a'
|
||||
on-error-container: '#ffdad6'
|
||||
primary-fixed: '#c4e7ff'
|
||||
primary-fixed-dim: '#7bd0ff'
|
||||
on-primary-fixed: '#001e2c'
|
||||
on-primary-fixed-variant: '#004c69'
|
||||
secondary-fixed: '#e1e0ff'
|
||||
secondary-fixed-dim: '#c0c1ff'
|
||||
on-secondary-fixed: '#07006c'
|
||||
on-secondary-fixed-variant: '#2f2ebe'
|
||||
tertiary-fixed: '#6ffbbe'
|
||||
tertiary-fixed-dim: '#4edea3'
|
||||
on-tertiary-fixed: '#002113'
|
||||
on-tertiary-fixed-variant: '#005236'
|
||||
background: '#0b1326'
|
||||
on-background: '#dae2fd'
|
||||
surface-variant: '#2d3449'
|
||||
typography:
|
||||
headline-lg:
|
||||
fontFamily: Space Grotesk
|
||||
fontSize: 18px
|
||||
fontWeight: '600'
|
||||
lineHeight: 24px
|
||||
letterSpacing: -0.01em
|
||||
headline-md:
|
||||
fontFamily: Space Grotesk
|
||||
fontSize: 15px
|
||||
fontWeight: '600'
|
||||
lineHeight: 20px
|
||||
letterSpacing: -0.005em
|
||||
headline-sm:
|
||||
fontFamily: Space Grotesk
|
||||
fontSize: 13px
|
||||
fontWeight: '500'
|
||||
lineHeight: 18px
|
||||
body-lg:
|
||||
fontFamily: Inter
|
||||
fontSize: 13px
|
||||
fontWeight: '400'
|
||||
lineHeight: 20px
|
||||
body-md:
|
||||
fontFamily: Inter
|
||||
fontSize: 12px
|
||||
fontWeight: '400'
|
||||
lineHeight: 18px
|
||||
body-sm:
|
||||
fontFamily: Inter
|
||||
fontSize: 11px
|
||||
fontWeight: '400'
|
||||
lineHeight: 16px
|
||||
code-editor:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 13px
|
||||
fontWeight: '400'
|
||||
lineHeight: 22px
|
||||
code-gutter:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 11px
|
||||
fontWeight: '400'
|
||||
lineHeight: 22px
|
||||
label-md:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 11px
|
||||
fontWeight: '500'
|
||||
lineHeight: 14px
|
||||
letterSpacing: 0.02em
|
||||
label-sm:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 10px
|
||||
fontWeight: '500'
|
||||
lineHeight: 12px
|
||||
letterSpacing: 0.03em
|
||||
status-indicator:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 10px
|
||||
fontWeight: '600'
|
||||
lineHeight: 12px
|
||||
rounded:
|
||||
sm: 0.125rem
|
||||
DEFAULT: 0.25rem
|
||||
md: 0.375rem
|
||||
lg: 0.5rem
|
||||
xl: 0.75rem
|
||||
full: 9999px
|
||||
spacing:
|
||||
gutter-xs: 0.25rem
|
||||
gutter-sm: 0.5rem
|
||||
gutter-md: 0.75rem
|
||||
gutter-lg: 1rem
|
||||
panel-padding-compact: 0.375rem
|
||||
panel-padding-default: 0.75rem
|
||||
window-bar-height: 2.25rem
|
||||
status-bar-height: 1.625rem
|
||||
sidebar-min-width: 15rem
|
||||
sidebar-default-width: 18rem
|
||||
sidebar-max-width: 26rem
|
||||
page-canvas-padding: 2rem
|
||||
---
|
||||
|
||||
## Brand & Style
|
||||
|
||||
This design system defines an engineering-grade, native-feeling desktop IDE engineered specifically for rigorous academic, standard-compliant (ГОСТ / md2gost), and technical document authoring.
|
||||
|
||||
### Personality & Tone
|
||||
- **Precision-Engineered:** Every line, margin, and rule feels calibrated with CAD-like certainty. There are no ornamental flourishes or arbitrary paddings.
|
||||
- **Academic Authority Meets Modern Tooling:** Bridges the gap between contemporary developer tooling (VS Code, Obsidian) and the strict, formal demands of state academic and technical publishing standards.
|
||||
- **Unobtrusive Immersion:** The dark UI recedes into the background, leaving center stage for high-contrast, mathematically correct A4 sheet previews and clean markdown source code.
|
||||
|
||||
### Design Style Movement
|
||||
- **Technical Studio Minimalism with Skeuomorphic Document Fidelity:** A high-density dark workspace environment composed of tonal layers, precise 1px borders, and low-saturation slate panels. This directly contrasts with an authentic physical paper canvas viewport simulating A4 aspect ratios, metric margins, and physical paper drop shadows.
|
||||
|
||||
## Colors
|
||||
|
||||
The palette establishes an ergonomic, fatigue-reducing environment for long-form writing and technical auditing.
|
||||
|
||||
### Palette Roles
|
||||
- **Primary (`#38bdf8` - Sky Blue):** Active cursor cues, focused outline nodes, live sync indicators, and interactive syntax tokens.
|
||||
- **Secondary (`#6366f1` - Indigo):** Structural elements, secondary selection bounds, active tabs, and git branch states.
|
||||
- **Tertiary / Success (`#10b981` - Emerald):** Passed ГОСТ compliance rules, healthy compilation states, and PlantUML render success.
|
||||
- **Warning (`#f59e0b` - Amber):** Lint warnings, missing references, table overflow notices, and unformatted bib items.
|
||||
- **Critical / Error (`#f43f5e` - Rose):** ГОСТ structural violations, syntax errors, and missing image anchors.
|
||||
- **Neutrals (Slate Tiers):**
|
||||
- Deepest Base (`#0b0f19`): Main application backdrop, title bar, and canvas workspace gutter.
|
||||
- Surface Level 1 (`#0f172a`): Primary sidebars, status bar, and panel trays.
|
||||
- Surface Level 2 (`#1e293b`): Active pane backgrounds, editor gutters, and menu flyouts.
|
||||
- Borders & Dividers (`#334155`): Strict 1px panel separations and tab dividers.
|
||||
- Muted Text (`#64748b`): Line numbers, metadata tags, and disabled states.
|
||||
- Primary UI Text (`#f1f5f9`): High-legibility UI labels, inspector values, and navigation titles.
|
||||
|
||||
### Document Preview Isolation
|
||||
- **Paper Canvas (`#ffffff`):** Pure print white, explicitly decoupled from the UI theme.
|
||||
- **Paper Ink (`#000000` to `#111827`):** High-density text reproduction conforming to strict print standards.
|
||||
|
||||
## Typography
|
||||
|
||||
The type system separates application chrome, markdown code editing, and compiled document rendering into three distinct semantic layers:
|
||||
|
||||
1. **Application Shell (`Space Grotesk` & `Inter`):** Space Grotesk provides structured, technical authority to section headers, panel drawers, and modal dialogs. Inter maintains neutral, uncompromised legibility for inspector panels, tree views, and configuration screens.
|
||||
2. **Editor Core (`JetBrains Mono`):** Dedicated to the markdown source pane, status metrics, and terminal diagnostics. It uses ligatures, fixed character bounds, and balanced punctuation to keep dense markdown, LaTeX math equations, and ГОСТ macro tags aligned.
|
||||
3. **Print Output Pane (Independent Document Engine):** The live ГОСТ preview bypasses application UI font rules entirely. It renders in true `Times New Roman` (or standard metrically equivalent `Liberation Serif` fallback) with explicit 14pt sizing, 1.5 line pitch, and standard 1.25cm paragraph indents as required by standard specifications.
|
||||
|
||||
## Layout & Spacing
|
||||
|
||||
This layout uses a continuous, rigid docked IDE grid without floating fluid gutters. The viewport is divided into distinct, resizable operational sectors:
|
||||
|
||||
### Grid & Pane Distribution
|
||||
- **Top Window Bar (36px):** Native OS controls, project switcher, build trigger, and quick layout toggles.
|
||||
- **Left Tool Palette & Sidebar (Collapsible, 240px–416px):** Accommodates file navigation, ГОСТ table-of-contents hierarchy, diagnostics inspector, and diagram snippets.
|
||||
- **Center Editor Workcell (Split 50/50 Default):**
|
||||
- **Left Sub-cell (Source):** Full-bleed markdown code buffer with fixed line gutters and diagnostic minimap.
|
||||
- **Right Sub-cell (Canvas):** Dark virtual desk (`#0b0f19`) containing an A4 sheet rendered to scale (ratio 1:1.414) with dynamic zoom levels.
|
||||
- **Bottom Status Ribbon (26px):** Persistent hardware and compilation state (active ГОСТ profile, word/page totals, syntax engine, PlantUML daemon state).
|
||||
|
||||
### Spacing Rules
|
||||
- UI density is compact; standard horizontal padding between metadata elements is 6px to 8px.
|
||||
- Pane splitters are 1px visual rules backed by a 5px interactive hit zone.
|
||||
- Paper canvas maintains an isolated virtual viewport with centered dynamic padding (minimum 32px on all sides) to preserve document boundary clarity.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
This system avoids expressive blur-heavy shadows in the application chrome, relying instead on mechanical surface layering and razor-sharp separation:
|
||||
|
||||
### Tonal Hierarchy
|
||||
- **Level 0 (App Shell/Workspace Cavity):** `#0b0f19` — the deepest recessed floor, visible beneath the canvas and title bar.
|
||||
- **Level 1 (Side Panels & Status Bar):** `#0f172a` — anchored static modules, bordered with 1px `#334155` solid strokes.
|
||||
- **Level 2 (Active Editor & Tool Panels):** `#1e293b` — interactive focal spaces.
|
||||
- **Level 3 (Overlays & Menus):** `#1e293b` with a 1px border of `#475569` and a precise drop shadow: `0 4px 16px -2px rgba(0, 0, 0, 0.5)`.
|
||||
|
||||
### Document Skeuomorphism
|
||||
The central preview pane treats the A4 sheet as a tangible physical substrate:
|
||||
- **Sheet Surface:** Solid `#ffffff`.
|
||||
- **Sheet Elevation:** A layered, neutral shadow that conveys true physical weight:
|
||||
- `box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 12px 32px -4px rgba(0, 0, 0, 0.65);`
|
||||
- **Page Breaks:** Explicit 16px gap between consecutive A4 sheets in continuous preview mode, showing paper borders and cut lines.
|
||||
|
||||
## Shapes
|
||||
|
||||
The design system uses a strict **Soft (`roundedness: 1`)** geometric model to preserve an industrial, engineering-focused look.
|
||||
|
||||
### Radii Tokens
|
||||
- **Base (0.25rem / 4px):** Form inputs, buttons, status chips, inspector rows, and tree nodes.
|
||||
- **Large (0.5rem / 8px):** Dialog windows, hover popovers, and quick-picker modals.
|
||||
- **Zero (0px):** Editor panes, status bar, title bar, tabs, preview paper, and pane divider rails.
|
||||
|
||||
Sharp geometric discipline ensures that nested panels lock cleanly into the desktop frame without wasteful dead corner space.
|
||||
|
||||
## Components
|
||||
|
||||
### Action Controls (Buttons)
|
||||
- **Primary Action (e.g., "Build PDF / ГОСТ"):** Solid `#38bdf8` fill, `#0f172a` bold text, 4px corner radius, 28px height. Focus states apply a 2px outer ring in `#38bdf8` with 2px offset.
|
||||
- **Secondary / Ghost Action:** Flat background with a 1px `#334155` border, `#f1f5f9` text, transitioning to `#1e293b` fill on hover.
|
||||
- **Icon Quick-actions:** 24x24px bounds, 0px border, transparent background, `#94a3b8` icon fill, transitioning to `#ffffff` with a `#334155` background on hover.
|
||||
|
||||
### Status Indicators & Badges
|
||||
- **Format Audit Badges:** Monospace text in 10px uppercase, padding 2px 6px, radius 2px.
|
||||
- *Compliant:* `#10b981` text over `rgba(16, 185, 129, 0.1)` background with `rgba(16, 185, 129, 0.25)` border.
|
||||
- *Non-compliant:* `#f43f5e` text over `rgba(244, 63, 94, 0.1)` background with `rgba(244, 63, 94, 0.25)` border.
|
||||
- **Compiler Daemon Light:** 6px circular dot with a soft radial glow when compiling, turning solid emerald when idle and amber during recalculation.
|
||||
|
||||
### Editor & Workspace Tabs
|
||||
- **Tab Header:** 32px height, 0px border radius, separated by 1px `#334155` vertical rules.
|
||||
- **Active Tab:** `#1e293b` background with a top 2px indicator line in `#38bdf8`.
|
||||
- **Inactive Tab:** `#0f172a` background with `#94a3b8` text.
|
||||
|
||||
### Tree Navigation (File Explorer & Outline)
|
||||
- 24px row height with a strict hierarchical indentation of 12px per level.
|
||||
- Left-aligned indicator caret with hover highlights spanning the full panel width (`rgba(51, 65, 85, 0.4)`).
|
||||
- Active item uses `#38bdf8` text with a subtle `rgba(56, 189, 248, 0.08)` background fill and a 2px left-border accent.
|
||||
|
||||
### Input Fields & Search Bars
|
||||
- Background `#0b0f19`, border 1px solid `#334155`, text `#f1f5f9`, font JetBrains Mono 12px.
|
||||
- Focused state transitions border to `#38bdf8` without changing the bounding box dimensions.
|
||||
|
||||
### Diagnostics & Validation Panel
|
||||
- List view segmented by file position `[Line:Col]`.
|
||||
- Severity icons: 12px monospace indicators (`[E]` in Rose, `[W]` in Amber, `[I]` in Sky Blue).
|
||||
- Direct click jumps the markdown source line and centers the preview canvas on the corresponding page section.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 930 KiB |
+7
-4
@@ -179,7 +179,7 @@ Windowed exe: `_argv_needs_console()` + `_enable_windows_console()` — конс
|
||||
| Таблица | `%id Подпись` перед таблицей | подпись «Таблица N — …» |
|
||||
| Merge ячеек | `^` rowspan, `>` colspan | OOXML `vMerge` / `gridSpan`; через разрыв страницы merge не переносится |
|
||||
| Листинг | `%id` + code fence | Courier New, опционально Pygments |
|
||||
| Диаграмма | `%id` + ` ```uml ` / `uml-c4` / `mermaid` / `idef0` / `dfd` | Рисунок; флаги `+listing`, `+landscape` |
|
||||
| Диаграмма | `%id` + ` ```uml ` / `uml-c4` / `mermaid` / `idef0` / `dfd` / `dfd-gs` | Рисунок; флаги `+listing`, `+landscape` |
|
||||
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` | номер только если есть ссылка |
|
||||
| Инлайн-формула | `$…$` | OMML в абзаце |
|
||||
| Ссылка на объект | `@Рисунок:id`, `@Таблица:id`, `@Листинг:id`, `@Формула:id` | подстановка номера |
|
||||
@@ -206,7 +206,7 @@ Windowed exe: `_argv_needs_console()` + `_enable_windows_console()` — конс
|
||||
2. локальный Kroki (`KROKI_URL` / `--kroki-url`);
|
||||
3. `https://kroki.io` при `--diagram-fallback remote`.
|
||||
|
||||
Mermaid — локально (браузер / QuickJS) или Kroki. **IDEF0** — оградка ` ```idef0 `, локальный Pillow (рамка NIST). **DFD** — оградка ` ```dfd `, пакет [data-flow-diagram](https://github.com/pbauermeister/dfd) → вшитый/кэш/PATH Graphviz (`scripts/fetch_graphviz.py`, GUI «Скачать Graphviz») или Kroki.
|
||||
Mermaid — локально (браузер / QuickJS) или Kroki. **IDEF0** — оградка ` ```idef0 `, локальный Pillow (рамка NIST). **DFD Йордона** — оградка ` ```dfd `, пакет [data-flow-diagram](https://github.com/pbauermeister/dfd) → вшитый/кэш/PATH Graphviz (`scripts/fetch_graphviz.py`, GUI «Скачать Graphviz») или Kroki. **DFD Гейна–Сарсона** — оградка ` ```dfd-gs `, локальный Pillow (штамп Ramus), Graphviz не нужен.
|
||||
|
||||
Кэш картинок: `{каталог_md}/.md2gost-cache/`.
|
||||
Кэш `!include` URL: `md2gost.include-cache.json` + `include-cache/`.
|
||||
@@ -507,12 +507,14 @@ DOCX сам не умеет писать «Продолжение Таблицы
|
||||
- `prepare_source`, `render_diagram`.
|
||||
- `resolve_plantuml_jar`, `fetch_plantuml_jar`, `iter_plantuml_candidates`, `diagram_engine_status`.
|
||||
- рендер jar / Kroki / пара png+svg; масштаб `scale` в исходник PlantUML.
|
||||
- локальные типы `LOCAL_ONLY_TYPES` (`idef0`, `dfd`): Pillow, без jar/Kroki.
|
||||
- локальные типы `LOCAL_ONLY_TYPES` (`idef0`, `dfd`, `dfd-gs`): без jar; `dfd-gs` и `idef0` — Pillow, `dfd` — Graphviz внутри `dfd.py`.
|
||||
|
||||
**`md2gost/idef0.py`** — DSL IDEF0 + Pillow/SVG.
|
||||
|
||||
**`md2gost/dfd.py`** — обёртка [pbauermeister/dfd](https://github.com/pbauermeister/dfd): DSL → DOT → vendor/system Graphviz / Kroki (`write_dfd_outputs`, `fetch_graphviz`).
|
||||
|
||||
**`md2gost/dfd_gs.py`** — DFD Гейна–Сарсона (` ```dfd-gs `): тот же каркас DSL, фигуры Ramus, Pillow/SVG.
|
||||
|
||||
**`md2gost/diagrams/`** — `schemes.json`, C4 `*.puml`.
|
||||
|
||||
### 6.8. Word COM и вёрстка
|
||||
@@ -630,6 +632,7 @@ DOCX сам не умеет писать «Продолжение Таблицы
|
||||
| `test_diagram_renderer.py` | схемы, кэш, mermaid, svg, jar, `+listing`/`+landscape` |
|
||||
| `test_idef0.py` | DSL IDEF0, PNG/SVG, интеграция `render_diagram` |
|
||||
| `test_dfd.py` | DFD data-flow-diagram, mock Graphviz/Kroki, интеграция `render_diagram` |
|
||||
| `test_dfd_gs.py` | DFD Гейна–Сарсона, Pillow, оградка не перехватывает ` ```dfd ` |
|
||||
| `test_md2fodt.py` | escape, каркас FODT, запись файла |
|
||||
| `test_md2latex.py` | longtable continuation, генерация проекта |
|
||||
| `test_word2md.py` | подписи, спецзаголовки, @ссылки, продолжения таблиц, DOCX→MD, round-trip |
|
||||
@@ -695,7 +698,7 @@ DOCX сам не умеет писать «Продолжение Таблицы
|
||||
|
||||
## 13. Чего нет / ограничения (честно)
|
||||
|
||||
- DFD: пакет `data-flow-diagram` в зависимостях; картинка — вшитый/кэш/PATH Graphviz или Kroki; балансировка уровней не проверяется.
|
||||
- DFD Йордона (` ```dfd `): пакет `data-flow-diagram`; картинка — вшитый/кэш/PATH Graphviz или Kroki; балансировка уровней не проверяется. DFD Гейна–Сарсона (` ```dfd-gs `) рисуется локально, три колонки, без обхода стрелок вокруг блоков.
|
||||
- FODT и LaTeX не догоняют md2gost по GUI, checker, схемам, профилям PIS/АПИД, Word COM.
|
||||
- «Продолжение таблицы» в чистом DOCX без Word невозможно точно (нет longtable). Без Word — `off` или неточный `legacy`/`caption`.
|
||||
- `--check-pages` даёт ложные срабатывания; это эвристика.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -51,7 +51,7 @@ if exist "%USERPROFILE%\.cargo\bin\uvx.exe" set "UVX_BIN=%USERPROFILE%\.cargo\bi
|
||||
if defined UVX_BIN goto :got_uvx
|
||||
|
||||
echo uvx не найден. Установите uv: https://docs.astral.sh/uv/getting-started/installation/
|
||||
echo powershell: irm https://astral.sh/uv/install.ps1 ^| iex
|
||||
echo powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
goto :fail
|
||||
|
||||
:got_uvx
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
tests/_idef0_preview.png
|
||||
tests/_idef0_preview.svg
|
||||
tests/_idef0_preview_a0.png
|
||||
tests/_idef0_preview_a0.svg
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.wordcraft/
|
||||
out/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,32 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
// CJS, не ESM: Electron дважды выполняет ESM-preload, и contextBridge падает.
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
format: 'cjs',
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: 'index.cjs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('src/shared'),
|
||||
},
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
},
|
||||
})
|
||||
Generated
+4333
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "ghosteditor-desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.14",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/space-grotesk": "^5.3.0",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tiptap/markdown": "^3.31.4",
|
||||
"@tiptap/pm": "^3.31.4",
|
||||
"@tiptap/react": "^3.31.4",
|
||||
"@tiptap/starter-kit": "^3.31.4",
|
||||
"material-symbols": "^0.48.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"electron": "^44.7.0",
|
||||
"electron-vite": "^5.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^7.3.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { HOST_NOT_CONNECTED, type EditorFile } from '../shared/host'
|
||||
import { openInWordcraft } from './wordcraft'
|
||||
|
||||
function registerHost(): void {
|
||||
for (const channel of ['host:open', 'host:save', 'host:check', 'host:build'] as const) {
|
||||
ipcMain.removeHandler(channel)
|
||||
ipcMain.handle(channel, () => HOST_NOT_CONNECTED)
|
||||
}
|
||||
ipcMain.removeHandler('host:edit')
|
||||
ipcMain.handle('host:edit', (_event, files: EditorFile[], activeId: string) => openInWordcraft(files, activeId))
|
||||
}
|
||||
|
||||
function registerChrome(): void {
|
||||
ipcMain.removeHandler('window:minimize')
|
||||
ipcMain.removeHandler('window:toggleMaximize')
|
||||
ipcMain.removeHandler('window:close')
|
||||
|
||||
ipcMain.handle('window:minimize', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
})
|
||||
ipcMain.handle('window:toggleMaximize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
if (win.isMaximized()) win.unmaximize()
|
||||
else win.maximize()
|
||||
})
|
||||
ipcMain.handle('window:close', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
const win = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 1180,
|
||||
minHeight: 720,
|
||||
show: false,
|
||||
frame: false,
|
||||
backgroundColor: '#0b1326',
|
||||
title: 'GhostEditor',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => win.show())
|
||||
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
void win.loadURL(devUrl)
|
||||
} else {
|
||||
void win.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerHost()
|
||||
registerChrome()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import type { EditorFile, HostResult } from '../shared/host'
|
||||
|
||||
const ROOT = join(tmpdir(), 'ghosteditor-docs')
|
||||
|
||||
export function findWordcraft(): string | null {
|
||||
const fromEnv = process.env.WORDCRAFT_EXE
|
||||
if (fromEnv && existsSync(fromEnv)) return fromEnv
|
||||
const roots = [process.cwd(), app.getAppPath(), join(app.getAppPath(), '..'), join(app.getAppPath(), '../..')]
|
||||
for (const root of roots) {
|
||||
const found = findExe(join(root, '.wordcraft', 'app'))
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findExe(dir: string): string | null {
|
||||
if (!existsSync(dir)) return null
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isFile() && entry.name.toLowerCase() === 'wordcraft.exe') return full
|
||||
if (entry.isDirectory()) {
|
||||
const nested = join(full, 'wordcraft.exe')
|
||||
if (existsSync(nested)) return nested
|
||||
const deeper = findExe(full)
|
||||
if (deeper) return deeper
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function safeRelative(id: string): string | null {
|
||||
const parts = id.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
if (parts.length === 0 || parts.some((part) => part === '..' || part.includes(':'))) return null
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
export async function openInWordcraft(files: EditorFile[], activeId: string): Promise<HostResult> {
|
||||
const exe = findWordcraft()
|
||||
if (!exe) return { ok: false, error: 'WordCraft не найден' }
|
||||
const active = safeRelative(activeId)
|
||||
if (!active) return { ok: false, error: 'некорректный путь файла' }
|
||||
|
||||
for (const file of files) {
|
||||
const relative = safeRelative(file.id)
|
||||
if (!relative) continue
|
||||
const path = join(ROOT, relative)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, file.text, 'utf8')
|
||||
}
|
||||
|
||||
const child = spawn(exe, [join(ROOT, active)], { detached: true, stdio: 'ignore', windowsHide: false })
|
||||
child.unref()
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { ChromeApi } from '../shared/chrome'
|
||||
import type { HostApi, HostResult } from '../shared/host'
|
||||
|
||||
const host: HostApi = {
|
||||
open: () => ipcRenderer.invoke('host:open') as Promise<HostResult>,
|
||||
save: (text) => ipcRenderer.invoke('host:save', text) as Promise<HostResult>,
|
||||
check: (text) => ipcRenderer.invoke('host:check', text) as Promise<HostResult>,
|
||||
build: (text) => ipcRenderer.invoke('host:build', text) as Promise<HostResult>,
|
||||
edit: (files, activeId) => ipcRenderer.invoke('host:edit', files, activeId) as Promise<HostResult>,
|
||||
}
|
||||
|
||||
const chrome: ChromeApi = {
|
||||
platform: process.platform,
|
||||
minimize: () => ipcRenderer.invoke('window:minimize') as Promise<void>,
|
||||
toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize') as Promise<void>,
|
||||
close: () => ipcRenderer.invoke('window:close') as Promise<void>,
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('host', host)
|
||||
contextBridge.exposeInMainWorld('frame', chrome)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GhostEditor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EditorHost } from './components/EditorHost'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { TitleBar } from './components/TitleBar'
|
||||
import { SessionProvider } from './session'
|
||||
import { SettingsProvider } from './settings'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<SettingsProvider>
|
||||
<Shell />
|
||||
</SettingsProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-surface text-on-surface">
|
||||
<TitleBar />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Sidebar />
|
||||
<EditorHost />
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
<StatusBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { NotionEditor } from '../editor/NotionEditor'
|
||||
import { SourceEditor } from '../editor/SourceEditor'
|
||||
import { useSession } from '../session'
|
||||
import { useSettings } from '../settings'
|
||||
import { WordPreview } from './WordPreview'
|
||||
|
||||
export function EditorHost() {
|
||||
const { text, setText, mode, setCursor, focusLine, focusToken, editorCommand, editorCommandToken, files, activeId } = useSession()
|
||||
const { settings } = useSettings()
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const lock = useRef(false)
|
||||
const editorScroll = useRef<(ratio: number) => void>(() => {})
|
||||
const source = mode !== 'notion'
|
||||
|
||||
const onEditorScroll = useCallback(
|
||||
(ratio: number) => {
|
||||
if (!settings.syncScroll || !settings.split || lock.current) return
|
||||
const pane = previewRef.current
|
||||
if (!pane) return
|
||||
const max = pane.scrollHeight - pane.clientHeight
|
||||
lock.current = true
|
||||
pane.scrollTop = ratio * Math.max(0, max)
|
||||
lock.current = false
|
||||
},
|
||||
[settings.syncScroll, settings.split],
|
||||
)
|
||||
|
||||
const bindScrollTo = useCallback((scrollTo: (ratio: number) => void) => {
|
||||
editorScroll.current = scrollTo
|
||||
}, [])
|
||||
|
||||
function onPreviewScroll(): void {
|
||||
if (!settings.syncScroll || lock.current) return
|
||||
const pane = previewRef.current
|
||||
if (!pane) return
|
||||
const max = pane.scrollHeight - pane.clientHeight
|
||||
const ratio = max <= 0 ? 0 : pane.scrollTop / max
|
||||
lock.current = true
|
||||
editorScroll.current(ratio)
|
||||
lock.current = false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-surface-container-lowest" style={{ zoom: settings.zoom / 100 }}>
|
||||
{text.trimStart().startsWith('<svg') && !settings.split ? (
|
||||
<div className="flex max-h-72 shrink-0 items-center justify-center border-b border-outline-variant/40 bg-[#1a2438] p-6">
|
||||
<div
|
||||
className="max-h-60 max-w-full bg-white p-4 [&_svg]:h-auto [&_svg]:max-h-52 [&_svg]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1">
|
||||
<SourceEditor
|
||||
active={source}
|
||||
text={text}
|
||||
focusLine={focusLine}
|
||||
focusToken={focusToken}
|
||||
fontSize={settings.fontSize}
|
||||
gutterSize={settings.gutterSize}
|
||||
showLineNumbers={settings.showLineNumbers}
|
||||
lineWrapping={settings.lineWrapping}
|
||||
highlightLine={settings.highlightLine}
|
||||
editorCommand={editorCommand}
|
||||
editorCommandToken={editorCommandToken}
|
||||
onScrollRatio={onEditorScroll}
|
||||
bindScrollTo={bindScrollTo}
|
||||
onChange={setText}
|
||||
onCursor={(line, col) => setCursor({ line, col })}
|
||||
/>
|
||||
<NotionEditor active={mode === 'notion'} text={text} onChange={setText} />
|
||||
</div>
|
||||
</div>
|
||||
{settings.split ? (
|
||||
<WordPreview
|
||||
ref={previewRef}
|
||||
text={text}
|
||||
fileName={files[activeId]?.name ?? ''}
|
||||
fileId={activeId}
|
||||
files={files}
|
||||
onScroll={onPreviewScroll}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HELP_TOPICS, type HelpTopic } from '../helpTopics'
|
||||
|
||||
export function HelpPanel({ topic, onClose }: { topic: HelpTopic; onClose: () => void }) {
|
||||
const [current, setCurrent] = useState(topic.id)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrent(topic.id)
|
||||
}, [topic.id])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const shown = HELP_TOPICS.find((item) => item.id === current) ?? topic
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55" onMouseDown={onClose}>
|
||||
<div
|
||||
className="flex max-h-[70vh] w-[40rem] overflow-hidden rounded-lg border border-outline-variant bg-surface-container-low shadow-2xl"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<nav className="flex w-44 shrink-0 flex-col gap-1 border-r border-outline-variant/60 bg-surface-container-lowest p-2">
|
||||
{HELP_TOPICS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setCurrent(item.id)}
|
||||
className={`rounded px-2 py-1.5 text-left text-[13px] ${
|
||||
item.id === shown.id
|
||||
? 'bg-surface-container-highest text-on-surface'
|
||||
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between px-4 pt-3">
|
||||
<h2 className="font-headline text-headline-md text-on-surface">{shown.title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
title="Закрыть справку"
|
||||
onClick={onClose}
|
||||
className="h-7 rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-4 pt-3 pb-4">
|
||||
{shown.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="mb-3 text-[13px] leading-5 text-on-surface-variant">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function Icon({ name, size = 16 }: { name: string; size?: number }) {
|
||||
return (
|
||||
<span className="material-symbols-outlined align-middle" style={{ fontSize: size }} aria-hidden>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { HostResult } from '@shared/host'
|
||||
import { HELP_TOPICS, type HelpTopic } from '../helpTopics'
|
||||
import { useSession, type EditorCommand } from '../session'
|
||||
import { useSettings } from '../settings'
|
||||
import { HelpPanel } from './HelpPanel'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
type MenuId = 'file' | 'edit' | 'select' | 'view' | 'help'
|
||||
|
||||
export function MenuBar() {
|
||||
const [menu, setMenu] = useState<MenuId | null>(null)
|
||||
const [about, setAbout] = useState(false)
|
||||
const [help, setHelp] = useState<HelpTopic | null>(null)
|
||||
const { text, files, activeId, setMode, setNotice, runEditorCommand } = useSession()
|
||||
const { settings, updateSettings, openSettings } = useSettings()
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu && !about) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (menu) setMenu(null)
|
||||
else setAbout(false)
|
||||
}
|
||||
const onPointer = (event: MouseEvent) => {
|
||||
const target = event.target
|
||||
const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
|
||||
if (element?.closest('[data-menu-root]')) return
|
||||
setMenu(null)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('mousedown', onPointer)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('mousedown', onPointer)
|
||||
}
|
||||
}, [menu, about])
|
||||
|
||||
function edit(command: EditorCommand): void {
|
||||
setMode('code')
|
||||
runEditorCommand(command)
|
||||
setMenu(null)
|
||||
}
|
||||
|
||||
async function host(task: () => Promise<HostResult>): Promise<void> {
|
||||
setMenu(null)
|
||||
try {
|
||||
const result = await task()
|
||||
setNotice(result.ok ? null : result.error)
|
||||
} catch {
|
||||
setNotice('не подключено')
|
||||
}
|
||||
}
|
||||
|
||||
function openMenu(id: MenuId): void {
|
||||
setMenu((current) => (current === id ? null : id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-menu-root className="no-drag flex h-full items-stretch">
|
||||
<div className="relative flex h-full">
|
||||
<MenuButton label="Файлы" open={menu === 'file'} onClick={() => openMenu('file')} onHover={() => menu && setMenu('file')} />
|
||||
{menu === 'file' ? (
|
||||
<Drop>
|
||||
<Item label="Открыть…" onClick={() => void host(() => window.host.open())} />
|
||||
<Item label="Сохранить" onClick={() => void host(() => window.host.save(text))} />
|
||||
<Item
|
||||
label="Открыть в WordCraft"
|
||||
onClick={() =>
|
||||
void host(() =>
|
||||
window.host.edit(
|
||||
Object.values(files).map((file) => ({ id: file.id, text: file.id === activeId ? text : file.text })),
|
||||
activeId,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Rule />
|
||||
<Item
|
||||
label="Настройки"
|
||||
onClick={() => {
|
||||
openSettings()
|
||||
setMenu(null)
|
||||
}}
|
||||
/>
|
||||
<Rule />
|
||||
<Item
|
||||
label="Выход"
|
||||
onClick={() => {
|
||||
setMenu(null)
|
||||
void window.frame.close()
|
||||
}}
|
||||
/>
|
||||
</Drop>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative flex h-full">
|
||||
<MenuButton label="Правка" open={menu === 'edit'} onClick={() => openMenu('edit')} onHover={() => menu && setMenu('edit')} />
|
||||
{menu === 'edit' ? (
|
||||
<Drop>
|
||||
<Item label="Отменить" shortcut="Ctrl+Z" onClick={() => edit('undo')} />
|
||||
<Item label="Повторить" shortcut="Ctrl+Y" onClick={() => edit('redo')} />
|
||||
</Drop>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative flex h-full">
|
||||
<MenuButton
|
||||
label="Выделение"
|
||||
open={menu === 'select'}
|
||||
onClick={() => openMenu('select')}
|
||||
onHover={() => menu && setMenu('select')}
|
||||
/>
|
||||
{menu === 'select' ? (
|
||||
<Drop>
|
||||
<Item label="Выделить всё" shortcut="Ctrl+A" onClick={() => edit('selectAll')} />
|
||||
</Drop>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative flex h-full">
|
||||
<MenuButton label="Вид" open={menu === 'view'} onClick={() => openMenu('view')} onHover={() => menu && setMenu('view')} />
|
||||
{menu === 'view' ? (
|
||||
<Drop>
|
||||
<Item
|
||||
label="Разделить экран"
|
||||
checked={settings.split}
|
||||
onClick={() => {
|
||||
updateSettings({ split: !settings.split })
|
||||
setMenu(null)
|
||||
}}
|
||||
/>
|
||||
<Item
|
||||
label="Синхронная прокрутка"
|
||||
checked={settings.split && settings.syncScroll}
|
||||
onClick={() => {
|
||||
if (!settings.split) updateSettings({ split: true, syncScroll: true })
|
||||
else updateSettings({ syncScroll: !settings.syncScroll })
|
||||
setMenu(null)
|
||||
}}
|
||||
/>
|
||||
<Rule />
|
||||
<Item
|
||||
label="Номера строк"
|
||||
checked={settings.showLineNumbers}
|
||||
onClick={() => updateSettings({ showLineNumbers: !settings.showLineNumbers })}
|
||||
/>
|
||||
<Item
|
||||
label="Перенос строк"
|
||||
checked={settings.lineWrapping}
|
||||
onClick={() => updateSettings({ lineWrapping: !settings.lineWrapping })}
|
||||
/>
|
||||
<Item
|
||||
label="Подсветка строки"
|
||||
checked={settings.highlightLine}
|
||||
onClick={() => updateSettings({ highlightLine: !settings.highlightLine })}
|
||||
/>
|
||||
<Rule />
|
||||
<Item
|
||||
label="Увеличить"
|
||||
onClick={() => {
|
||||
updateSettings({ zoom: Math.min(180, settings.zoom + 10) })
|
||||
setMenu(null)
|
||||
}}
|
||||
/>
|
||||
<Item
|
||||
label="Уменьшить"
|
||||
onClick={() => {
|
||||
updateSettings({ zoom: Math.max(70, settings.zoom - 10) })
|
||||
setMenu(null)
|
||||
}}
|
||||
/>
|
||||
</Drop>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative flex h-full">
|
||||
<MenuButton label="Справка" open={menu === 'help'} onClick={() => openMenu('help')} onHover={() => menu && setMenu('help')} />
|
||||
{menu === 'help' ? (
|
||||
<Drop>
|
||||
{HELP_TOPICS.map((topic) => (
|
||||
<Item
|
||||
key={topic.id}
|
||||
label={topic.title}
|
||||
onClick={() => {
|
||||
setMenu(null)
|
||||
setHelp(topic)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Item
|
||||
label="О программе"
|
||||
onClick={() => {
|
||||
setMenu(null)
|
||||
setAbout(true)
|
||||
}}
|
||||
/>
|
||||
</Drop>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{help ? <HelpPanel topic={help} onClose={() => setHelp(null)} /> : null}
|
||||
{about ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55" onMouseDown={() => setAbout(false)}>
|
||||
<div
|
||||
className="w-80 rounded-lg border border-outline-variant bg-surface-container-low p-4 shadow-2xl"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="font-headline text-headline-md text-on-surface">GhostEditor</h2>
|
||||
<p className="mt-2 text-[13px] leading-5 text-on-surface-variant">
|
||||
Черновик отчёта пишется здесь. Страницу с полями, стилями и лентой открывает WordCraft. Сборку по ГОСТ по-прежнему
|
||||
делает конвейер md2gost.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAbout(false)}
|
||||
className="mt-4 h-7 rounded bg-primary-container px-3 font-code text-label-sm text-on-primary-container"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuButton({
|
||||
label,
|
||||
open,
|
||||
onClick,
|
||||
onHover,
|
||||
}: {
|
||||
label: string
|
||||
open: boolean
|
||||
onClick: () => void
|
||||
onHover: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={onHover}
|
||||
className={`px-2.5 text-[13px] ${open ? 'bg-surface-container-high text-on-surface' : 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Drop({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="absolute top-full left-0 z-40 min-w-56 border border-outline-variant bg-surface-container py-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Item({
|
||||
label,
|
||||
shortcut,
|
||||
checked,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
shortcut?: string
|
||||
checked?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-[13px] text-on-surface hover:bg-surface-container-highest"
|
||||
>
|
||||
<span className="flex w-4 shrink-0 justify-center text-primary">{checked ? <Icon name="check" size={14} /> : null}</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
{shortcut ? <span className="font-code text-[11px] text-outline">{shortcut}</span> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Rule() {
|
||||
return <div className="my-1 h-px bg-outline-variant/60" />
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSettings, type PreviewMargin, type PreviewSpacing } from '../settings'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
type CategoryId = 'editor' | 'view' | 'preview' | 'document'
|
||||
|
||||
const CATEGORIES: Array<{ id: CategoryId; label: string }> = [
|
||||
{ id: 'editor', label: 'Редактор' },
|
||||
{ id: 'view', label: 'Вид' },
|
||||
{ id: 'preview', label: 'Предпросмотр' },
|
||||
{ id: 'document', label: 'Документ' },
|
||||
]
|
||||
|
||||
const SPACING: Array<{ id: PreviewSpacing; label: string }> = [
|
||||
{ id: 100, label: '1' },
|
||||
{ id: 115, label: '1,15' },
|
||||
{ id: 150, label: '1,5' },
|
||||
]
|
||||
|
||||
const MARGINS: Array<{ id: PreviewMargin; label: string }> = [
|
||||
{ id: 'narrow', label: 'Узкие' },
|
||||
{ id: 'normal', label: 'Обычные' },
|
||||
{ id: 'wide', label: 'Широкие' },
|
||||
]
|
||||
|
||||
export function SettingsPanel() {
|
||||
const { settings, updateSettings, resetEditorSettings, resetPreviewSettings, open, toggleOpen } = useSettings()
|
||||
const [category, setCategory] = useState<CategoryId>('editor')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') toggleOpen()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, toggleOpen])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<aside className="flex w-[26rem] shrink-0 flex-col border-l border-outline-variant/50 bg-surface-container-low">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between px-3">
|
||||
<span className="font-body text-[11px] font-semibold tracking-wider text-on-surface-variant uppercase">
|
||||
Настройки
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Закрыть настройки"
|
||||
onClick={toggleOpen}
|
||||
className="flex h-[22px] w-[22px] items-center justify-center rounded text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<nav className="flex w-36 shrink-0 flex-col border-r border-outline-variant/40 py-1">
|
||||
{CATEGORIES.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setCategory(item.id)}
|
||||
className={`mx-1 h-7 rounded px-2 text-left text-[13px] ${
|
||||
category === item.id
|
||||
? 'bg-surface-container-highest text-on-surface'
|
||||
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-3 py-3">
|
||||
{category === 'editor' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Slider
|
||||
label="Шрифт кода"
|
||||
min={11}
|
||||
max={20}
|
||||
value={settings.fontSize}
|
||||
suffix="px"
|
||||
onChange={(fontSize) => updateSettings({ fontSize })}
|
||||
/>
|
||||
<Slider
|
||||
label="Номера строк"
|
||||
min={8}
|
||||
max={16}
|
||||
value={settings.gutterSize}
|
||||
suffix="px"
|
||||
onChange={(gutterSize) => updateSettings({ gutterSize })}
|
||||
/>
|
||||
<Check
|
||||
label="Показывать номера строк"
|
||||
checked={settings.showLineNumbers}
|
||||
onChange={(showLineNumbers) => updateSettings({ showLineNumbers })}
|
||||
/>
|
||||
<Check
|
||||
label="Переносить длинные строки"
|
||||
checked={settings.lineWrapping}
|
||||
onChange={(lineWrapping) => updateSettings({ lineWrapping })}
|
||||
/>
|
||||
<Check
|
||||
label="Подсвечивать активную строку"
|
||||
checked={settings.highlightLine}
|
||||
onChange={(highlightLine) => updateSettings({ highlightLine })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetEditorSettings}
|
||||
className="h-7 self-start rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
>
|
||||
Сбросить редактор
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{category === 'view' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Slider
|
||||
label="Масштаб"
|
||||
min={70}
|
||||
max={180}
|
||||
step={10}
|
||||
value={settings.zoom}
|
||||
suffix="%"
|
||||
onChange={(zoom) => updateSettings({ zoom })}
|
||||
/>
|
||||
<Check
|
||||
label="Разделить экран"
|
||||
checked={settings.split}
|
||||
onChange={(split) => updateSettings({ split })}
|
||||
/>
|
||||
<Check
|
||||
label="Синхронная прокрутка"
|
||||
checked={settings.syncScroll}
|
||||
onChange={(syncScroll) => updateSettings({ syncScroll, split: syncScroll ? true : settings.split })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{category === 'preview' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Slider
|
||||
label="Шрифт страницы"
|
||||
min={12}
|
||||
max={20}
|
||||
value={settings.previewFontSize}
|
||||
suffix="px"
|
||||
onChange={(previewFontSize) => updateSettings({ previewFontSize })}
|
||||
/>
|
||||
<Choice
|
||||
label="Межстрочный интервал"
|
||||
value={settings.previewSpacing}
|
||||
options={SPACING}
|
||||
onChange={(previewSpacing) => updateSettings({ previewSpacing })}
|
||||
/>
|
||||
<Choice
|
||||
label="Поля"
|
||||
value={settings.previewMargin}
|
||||
options={MARGINS}
|
||||
onChange={(previewMargin) => updateSettings({ previewMargin })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetPreviewSettings}
|
||||
className="h-7 self-start rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
>
|
||||
Сбросить предпросмотр
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{category === 'document' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<TextField
|
||||
label="ФИО"
|
||||
value={settings.student}
|
||||
placeholder="Иванов Иван Иванович"
|
||||
onChange={(student) => updateSettings({ student })}
|
||||
/>
|
||||
<TextField
|
||||
label="Группа"
|
||||
value={settings.group}
|
||||
placeholder="ИКБО-00-00"
|
||||
onChange={(group) => updateSettings({ group })}
|
||||
/>
|
||||
<p className="text-[11px] leading-4 text-outline">Профиль документа выбирается в левой панели.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function Slider({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
value,
|
||||
suffix,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
value: number
|
||||
suffix: string
|
||||
onChange: (value: number) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="flex items-center justify-between text-[13px] text-on-surface">
|
||||
{label}
|
||||
<span className="font-code text-label-sm text-primary">
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-[13px] text-on-surface">
|
||||
<input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} className="accent-primary" />
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Choice<T extends string | number>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
options: ReadonlyArray<{ id: T; label: string }>
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[13px] text-on-surface">{label}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={String(option.id)}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`h-7 rounded px-2 font-code text-label-sm ${
|
||||
option.id === value
|
||||
? 'bg-primary-container text-on-primary-container'
|
||||
: 'bg-surface-container text-on-surface-variant hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
placeholder: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[13px] text-on-surface">{label}</span>
|
||||
<input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-8 rounded border border-outline-variant bg-surface-container-lowest px-2 font-body text-[13px] text-on-surface outline-none select-text placeholder:text-outline focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
||||
import { folderIds, type WorkspaceFile } from '../documents'
|
||||
import {
|
||||
DEFAULT_EXPANDED,
|
||||
WORKSPACE_NAME,
|
||||
documentHeadings,
|
||||
parentMap,
|
||||
visibleRows,
|
||||
type Heading,
|
||||
type TreeNode,
|
||||
} from '../explorer'
|
||||
import { useSession } from '../session'
|
||||
import { buildTree } from '../tree'
|
||||
import { DOC_PROFILES, useSettings, type DocProfileId } from '../settings'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
type SideView = 'files' | 'search' | 'outline'
|
||||
|
||||
const VIEWS: Array<{ id: SideView; icon: string; title: string }> = [
|
||||
{ id: 'files', icon: 'folder', title: 'Проводник' },
|
||||
{ id: 'search', icon: 'search', title: 'Поиск' },
|
||||
{ id: 'outline', icon: 'toc', title: 'Структура' },
|
||||
]
|
||||
|
||||
const ACTIVITY_WIDTH = 48
|
||||
const PANEL_MIN = 200
|
||||
const PANEL_MAX = 420
|
||||
const PANEL_DEFAULT = 240
|
||||
const WIDTH_KEY = 'ghosteditor.sidebar.width'
|
||||
|
||||
function readWidth(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(WIDTH_KEY)
|
||||
const value = raw ? Number(raw) : PANEL_DEFAULT
|
||||
if (!Number.isFinite(value)) return PANEL_DEFAULT
|
||||
return Math.min(PANEL_MAX, Math.max(PANEL_MIN, value))
|
||||
} catch {
|
||||
return PANEL_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
function clampWidth(value: number): number {
|
||||
return Math.min(PANEL_MAX, Math.max(PANEL_MIN, value))
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { open: settingsOpen, toggleOpen } = useSettings()
|
||||
const [view, setView] = useState<SideView>('files')
|
||||
const [panelOpen, setPanelOpen] = useState(true)
|
||||
const [width, setWidth] = useState(readWidth)
|
||||
const [resizing, setResizing] = useState(false)
|
||||
|
||||
function choose(next: SideView): void {
|
||||
if (view === next && panelOpen) {
|
||||
setPanelOpen(false)
|
||||
return
|
||||
}
|
||||
setView(next)
|
||||
setPanelOpen(true)
|
||||
}
|
||||
|
||||
function startResize(event: ReactPointerEvent<HTMLDivElement>): void {
|
||||
event.preventDefault()
|
||||
const sash = event.currentTarget
|
||||
const startX = event.clientX
|
||||
const startW = width
|
||||
setResizing(true)
|
||||
const onMove = (ev: PointerEvent) => setWidth(clampWidth(startW + ev.clientX - startX))
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
sash.removeEventListener('pointermove', onMove)
|
||||
sash.removeEventListener('pointerup', onUp)
|
||||
sash.removeEventListener('pointercancel', onUp)
|
||||
setResizing(false)
|
||||
const next = clampWidth(startW + ev.clientX - startX)
|
||||
setWidth(next)
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(next))
|
||||
} catch {
|
||||
/* ширина просто не запомнится */
|
||||
}
|
||||
}
|
||||
sash.setPointerCapture(event.pointerId)
|
||||
sash.addEventListener('pointermove', onMove)
|
||||
sash.addEventListener('pointerup', onUp)
|
||||
sash.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 shrink-0 overflow-hidden"
|
||||
style={{ width: panelOpen ? ACTIVITY_WIDTH + width : ACTIVITY_WIDTH }}
|
||||
>
|
||||
<nav className="flex h-full w-12 shrink-0 flex-col items-center border-r border-outline-variant/50 bg-surface-container-lowest py-1">
|
||||
{VIEWS.map((item) => {
|
||||
const active = panelOpen && view === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={active ? `Скрыть «${item.title}»` : item.title}
|
||||
aria-pressed={active}
|
||||
onClick={() => choose(item.id)}
|
||||
className={`relative flex h-12 w-12 items-center justify-center ${
|
||||
active ? 'text-on-surface' : 'text-outline hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{active ? <span className="absolute top-3 bottom-3 left-0 w-0.5 rounded-r bg-primary" /> : null}
|
||||
<Icon name={item.icon} size={22} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
title={settingsOpen ? 'Закрыть настройки' : 'Настройки'}
|
||||
aria-pressed={settingsOpen}
|
||||
onClick={toggleOpen}
|
||||
className={`relative mt-auto flex h-12 w-12 items-center justify-center hover:text-on-surface ${
|
||||
settingsOpen ? 'text-on-surface' : 'text-outline'
|
||||
}`}
|
||||
>
|
||||
{settingsOpen ? <span className="absolute top-3 bottom-3 left-0 w-0.5 rounded-r bg-primary" /> : null}
|
||||
<Icon name="settings" size={22} />
|
||||
</button>
|
||||
</nav>
|
||||
{panelOpen ? (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-r border-outline-variant/50 bg-surface-container-low">
|
||||
<ProfileRow />
|
||||
{view === 'files' ? <Explorer /> : null}
|
||||
{view === 'search' ? <SearchPanel /> : null}
|
||||
{view === 'outline' ? <OutlinePanel /> : null}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="Потяните, чтобы изменить ширину"
|
||||
onPointerDown={startResize}
|
||||
className={`absolute top-0 right-0 z-10 h-full w-1 cursor-ew-resize touch-none ${
|
||||
resizing ? 'bg-primary' : 'hover:bg-primary/70'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Explorer() {
|
||||
const { text, files, folders, activeId, openFile, createFile, createFolder, activity, setMode, focusLineAt } = useSession()
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(DEFAULT_EXPANDED))
|
||||
const [selected, setSelected] = useState(activeId)
|
||||
const [sections, setSections] = useState({ files: true, outline: false, log: false })
|
||||
const treeRef = useRef<HTMLDivElement>(null)
|
||||
const filesRef = useRef(files)
|
||||
filesRef.current = files
|
||||
const roots = useMemo(() => buildTree(Object.values(files), folders), [files, folders])
|
||||
const parents = useMemo(() => parentMap(roots), [roots])
|
||||
const rows = visibleRows(roots, expanded)
|
||||
const headings = documentHeadings(text)
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(activeId)
|
||||
const file = filesRef.current[activeId]
|
||||
if (!file) return
|
||||
setExpanded((prev) => {
|
||||
const ids = folderIds(file.dir)
|
||||
if (ids.every((id) => prev.has(id))) return prev
|
||||
const next = new Set(prev)
|
||||
for (const id of ids) next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [activeId])
|
||||
|
||||
useEffect(() => {
|
||||
const node = treeRef.current?.querySelector(`[data-tree-id="${CSS.escape(selected)}"]`)
|
||||
if (node instanceof HTMLElement) node.scrollIntoView({ block: 'nearest' })
|
||||
}, [selected, expanded])
|
||||
|
||||
function toggle(id: string): void {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
|
||||
const index = rows.findIndex((row) => row.node.id === selected)
|
||||
if (index < 0) return
|
||||
const current = rows[index]
|
||||
if (!current) return
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
const next = rows[index + 1]
|
||||
if (next) setSelected(next.node.id)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
const prev = rows[index - 1]
|
||||
if (prev) setSelected(prev.node.id)
|
||||
} else if (event.key === 'ArrowRight' && current.node.kind === 'folder') {
|
||||
event.preventDefault()
|
||||
const next = rows[index + 1]
|
||||
if (!expanded.has(current.node.id)) toggle(current.node.id)
|
||||
else if (next && next.depth > current.depth) setSelected(next.node.id)
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
if (current.node.kind === 'folder' && expanded.has(current.node.id)) toggle(current.node.id)
|
||||
else {
|
||||
const parent = parents.get(current.node.id)
|
||||
if (parent) setSelected(parent)
|
||||
}
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (current.node.kind === 'folder') toggle(current.node.id)
|
||||
else openFile(current.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
function jump(line: number): void {
|
||||
setMode('code')
|
||||
focusLineAt(line)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ViewHeader title="Проводник">
|
||||
<HeaderButton
|
||||
icon="note_add"
|
||||
title="Новый файл в mirea"
|
||||
onClick={() => {
|
||||
createFile()
|
||||
}}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon="create_new_folder"
|
||||
title="Новая папка в mirea"
|
||||
onClick={() => {
|
||||
const id = createFolder()
|
||||
setExpanded((prev) => new Set(prev).add('dir:mirea').add(id))
|
||||
setSelected(id)
|
||||
}}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon="refresh"
|
||||
title="Вернуть раскрытие по умолчанию"
|
||||
onClick={() => {
|
||||
setExpanded(new Set(DEFAULT_EXPANDED))
|
||||
setSelected(activeId)
|
||||
}}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon="unfold_less"
|
||||
title="Свернуть папки"
|
||||
onClick={() => {
|
||||
setExpanded(new Set())
|
||||
setSelected((current) => (roots.some((node) => node.id === current) ? current : 'dir:mirea'))
|
||||
}}
|
||||
/>
|
||||
</ViewHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<Section
|
||||
title={WORKSPACE_NAME}
|
||||
open={sections.files}
|
||||
grow
|
||||
onToggle={() => setSections((prev) => ({ ...prev, files: !prev.files }))}
|
||||
>
|
||||
<div
|
||||
ref={treeRef}
|
||||
role="tree"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
className="h-full overflow-auto py-0.5 outline-none"
|
||||
>
|
||||
{rows.map(({ node, depth }) => (
|
||||
<TreeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={depth}
|
||||
expanded={expanded.has(node.id)}
|
||||
selected={node.id === selected}
|
||||
dirty={isDirty(files[node.id])}
|
||||
openInEditor={node.id === activeId}
|
||||
onSelect={() => {
|
||||
setSelected(node.id)
|
||||
if (node.kind === 'folder') toggle(node.id)
|
||||
else openFile(node.id)
|
||||
treeRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section
|
||||
title="Структура"
|
||||
ruled
|
||||
open={sections.outline}
|
||||
onToggle={() => setSections((prev) => ({ ...prev, outline: !prev.outline }))}
|
||||
>
|
||||
<HeadingList headings={headings} onJump={jump} />
|
||||
</Section>
|
||||
<Section
|
||||
title="Журнал"
|
||||
ruled
|
||||
open={sections.log}
|
||||
onToggle={() => setSections((prev) => ({ ...prev, log: !prev.log }))}
|
||||
>
|
||||
{activity.length === 0 ? (
|
||||
<p className="px-3 py-1.5 text-[13px] text-on-surface-variant">Действий ещё не было</p>
|
||||
) : (
|
||||
activity.map((entry, index) => (
|
||||
<p key={`${entry}-${index}`} className="truncate px-3 py-0.5 text-[13px] text-on-surface-variant">
|
||||
{entry}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isDirty(file: WorkspaceFile | undefined): boolean {
|
||||
return Boolean(file && file.text !== file.initial)
|
||||
}
|
||||
|
||||
type SearchHit = {
|
||||
key: string
|
||||
fileId: string
|
||||
label: string
|
||||
directory: string
|
||||
line?: number
|
||||
icon: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function searchHits(files: WorkspaceFile[], needle: string): SearchHit[] {
|
||||
const hits: SearchHit[] = []
|
||||
for (const file of files) {
|
||||
const directory = [WORKSPACE_NAME, ...file.dir].join(' / ')
|
||||
if (file.name.toLocaleLowerCase('ru').includes(needle)) {
|
||||
hits.push({
|
||||
key: `name:${file.id}`,
|
||||
fileId: file.id,
|
||||
label: file.name,
|
||||
directory,
|
||||
icon: file.icon,
|
||||
tone: file.tone,
|
||||
})
|
||||
}
|
||||
const lines = file.text.split('\n')
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (hits.length >= 40) return hits
|
||||
const line = lines[index] ?? ''
|
||||
if (!line.toLocaleLowerCase('ru').includes(needle)) continue
|
||||
const label = line.trim()
|
||||
if (!label) continue
|
||||
hits.push({
|
||||
key: `line:${file.id}:${index}`,
|
||||
fileId: file.id,
|
||||
label: label.slice(0, 96),
|
||||
directory: file.name,
|
||||
line: index + 1,
|
||||
icon: file.icon,
|
||||
tone: file.tone,
|
||||
})
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function SearchPanel() {
|
||||
const { files, openFile } = useSession()
|
||||
const [query, setQuery] = useState('')
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const needle = query.trim().toLocaleLowerCase('ru')
|
||||
const hits = needle ? searchHits(Object.values(files), needle) : []
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ViewHeader title="Поиск" />
|
||||
<div className="px-3 pb-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Имя файла или текст"
|
||||
className="h-7 w-full rounded border border-outline-variant bg-surface-container-lowest px-2 font-body text-[13px] text-on-surface outline-none placeholder:text-outline focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{!needle ? <p className="px-3 py-1 text-[13px] text-outline">Введите имя файла или фрагмент текста</p> : null}
|
||||
{needle && hits.length === 0 ? <p className="px-3 py-1 text-[13px] text-outline">Ничего не найдено</p> : null}
|
||||
{hits.map((hit) => (
|
||||
<button
|
||||
key={hit.key}
|
||||
type="button"
|
||||
title={hit.line ? `${hit.directory}, строка ${hit.line}` : hit.directory}
|
||||
onClick={() => {
|
||||
setSelected(hit.key)
|
||||
openFile(hit.fileId, hit.line ?? 1)
|
||||
}}
|
||||
className={`flex h-[22px] w-full items-center gap-1.5 px-3 text-left text-[13px] ${
|
||||
selected === hit.key
|
||||
? 'bg-surface-container-highest text-on-surface'
|
||||
: 'text-on-surface hover:bg-surface-container'
|
||||
}`}
|
||||
>
|
||||
<span className={hit.tone}>
|
||||
<Icon name={hit.icon} size={16} />
|
||||
</span>
|
||||
<span className="truncate">{highlightName(hit.label, query)}</span>
|
||||
<span className="ml-auto shrink-0 pl-2 text-[11px] text-outline">
|
||||
{hit.line ? `:${hit.line}` : hit.directory}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OutlinePanel() {
|
||||
const { text, setMode, focusLineAt } = useSession()
|
||||
const headings = documentHeadings(text)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ViewHeader title="Структура" />
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<HeadingList
|
||||
headings={headings}
|
||||
onJump={(line) => {
|
||||
setMode('code')
|
||||
focusLineAt(line)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewHeader({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-9 shrink-0 items-center justify-between gap-2 pr-2 pl-4">
|
||||
<span className="truncate font-body text-[11px] font-semibold tracking-wider text-on-surface-variant uppercase">
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex items-center">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderButton({ icon, title, onClick }: { icon: string; title: string; onClick?: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className="flex h-[22px] w-[22px] items-center justify-center rounded text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
grow = false,
|
||||
ruled = false,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
grow?: boolean
|
||||
ruled?: boolean
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`flex flex-col ${ruled ? 'border-t border-outline-variant/50' : ''} ${
|
||||
open && grow ? 'min-h-0 flex-1' : 'shrink-0'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex h-[22px] shrink-0 items-center gap-0.5 px-0.5 text-left hover:bg-surface-container"
|
||||
>
|
||||
<span className="text-on-surface-variant">
|
||||
<Icon name={open ? 'expand_more' : 'chevron_right'} size={16} />
|
||||
</span>
|
||||
<span className="truncate font-body text-[11px] font-semibold tracking-wide text-on-surface-variant uppercase">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className={grow ? 'min-h-0 flex-1 overflow-hidden' : 'max-h-48 overflow-auto'}>{children}</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
selected,
|
||||
dirty,
|
||||
openInEditor,
|
||||
onSelect,
|
||||
}: {
|
||||
node: TreeNode
|
||||
depth: number
|
||||
expanded: boolean
|
||||
selected: boolean
|
||||
dirty: boolean
|
||||
openInEditor: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const folder = node.kind === 'folder'
|
||||
const title = folder ? node.name : openInEditor ? `${node.name} — открыт` : node.name
|
||||
return (
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-expanded={folder ? expanded : undefined}
|
||||
aria-selected={selected}
|
||||
data-tree-id={node.id}
|
||||
title={title}
|
||||
onClick={onSelect}
|
||||
className={`relative flex h-[22px] cursor-pointer items-center pr-2 text-[13px] leading-none ${
|
||||
selected ? 'bg-surface-container-highest text-on-surface' : 'text-on-surface hover:bg-surface-container'
|
||||
}`}
|
||||
style={{ paddingLeft: depth * 12 }}
|
||||
>
|
||||
{Array.from({ length: depth }, (_, level) => (
|
||||
<span
|
||||
key={level}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px bg-outline-variant/70"
|
||||
style={{ left: level * 12 + 7 }}
|
||||
/>
|
||||
))}
|
||||
<span className="flex w-4 shrink-0 items-center justify-center text-on-surface-variant">
|
||||
{folder ? <Icon name={expanded ? 'expand_more' : 'chevron_right'} size={16} /> : null}
|
||||
</span>
|
||||
<span className={node.tone}>
|
||||
<Icon name={folder && expanded ? 'folder_open' : node.icon} size={16} />
|
||||
</span>
|
||||
<span className="ml-1.5 min-w-0 flex-1 truncate">{node.name}</span>
|
||||
{dirty ? (
|
||||
<span className="ml-2 flex shrink-0 items-center gap-1.5" title="Есть несохранённые правки">
|
||||
<span className="font-code text-[11px] text-tertiary">M</span>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-tertiary" />
|
||||
</span>
|
||||
) : openInEditor ? (
|
||||
<span className="ml-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeadingList({ headings, onJump }: { headings: Heading[]; onJump: (line: number) => void }) {
|
||||
if (headings.length === 0) {
|
||||
return <p className="px-3 py-1.5 text-[13px] text-outline">В документе нет заголовков</p>
|
||||
}
|
||||
return (
|
||||
<div className="py-0.5">
|
||||
{headings.map((heading) => (
|
||||
<button
|
||||
key={`${heading.line}:${heading.title}`}
|
||||
type="button"
|
||||
title={`Перейти к строке ${heading.line}`}
|
||||
onClick={() => onJump(heading.line)}
|
||||
className="flex h-[22px] w-full items-center gap-1.5 pr-2 text-left text-[13px] text-on-surface hover:bg-surface-container"
|
||||
style={{ paddingLeft: 8 + (heading.level - 1) * 12 }}
|
||||
>
|
||||
<span className="w-4 shrink-0 text-center font-code text-[10px] text-outline">{heading.level}</span>
|
||||
<span className="truncate">{heading.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileRow() {
|
||||
const { settings, updateSettings } = useSettings()
|
||||
return (
|
||||
<label className="flex h-9 shrink-0 items-center gap-2 border-b border-outline-variant/50 px-3">
|
||||
<span className="shrink-0 font-body text-[11px] font-semibold tracking-wide text-on-surface-variant uppercase">
|
||||
Профиль
|
||||
</span>
|
||||
<select
|
||||
value={settings.profile}
|
||||
title="Профиль документа"
|
||||
onChange={(event) => updateSettings({ profile: event.target.value as DocProfileId })}
|
||||
className="h-7 min-w-0 flex-1 cursor-pointer rounded border border-outline-variant bg-surface-container-lowest px-1.5 font-code text-[12px] text-on-surface outline-none focus:border-primary"
|
||||
>
|
||||
{DOC_PROFILES.map((item) => (
|
||||
<option key={item.id} value={item.id} className="bg-surface-container-high">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function highlightName(name: string, query: string): ReactNode {
|
||||
const needle = query.trim()
|
||||
const index = name.toLocaleLowerCase('ru').indexOf(needle.toLocaleLowerCase('ru'))
|
||||
if (!needle || index < 0) return name
|
||||
return (
|
||||
<>
|
||||
{name.slice(0, index)}
|
||||
<span className="text-primary">{name.slice(index, index + needle.length)}</span>
|
||||
{name.slice(index + needle.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { documentStats } from '../documents'
|
||||
import { useSession } from '../session'
|
||||
import { useSettings } from '../settings'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export function StatusBar() {
|
||||
const { text, cursor, mode, notice, files, activeId } = useSession()
|
||||
const { settings, updateSettings } = useSettings()
|
||||
const stats = documentStats(text)
|
||||
const file = files[activeId]
|
||||
const zoom = settings.zoom
|
||||
const dirty = Boolean(file && file.text !== file.initial)
|
||||
|
||||
return (
|
||||
<footer className="flex h-status-bar-height shrink-0 items-center justify-between gap-gutter-md bg-surface-container px-gutter-md font-code text-label-sm text-on-surface-variant">
|
||||
<div className="flex min-w-0 items-center gap-gutter-md">
|
||||
<span className="flex max-w-52 items-center gap-1 truncate" title={file?.id ?? ''}>
|
||||
<Icon name="description" size={13} />
|
||||
{file?.name ?? 'документ'}
|
||||
{dirty ? <span className="text-tertiary">M</span> : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="data_object" size={13} />
|
||||
UTF-8
|
||||
</span>
|
||||
{mode === 'notion' ? (
|
||||
<span>Блоки</span>
|
||||
) : (
|
||||
<span>
|
||||
Ln {cursor.line}, Col {cursor.col}
|
||||
</span>
|
||||
)}
|
||||
<span>{stats.words.toLocaleString('ru-RU')} слов</span>
|
||||
<span>{stats.tables} табл.</span>
|
||||
<span>{stats.figures} рис.</span>
|
||||
<span>{stats.formulas} форм.</span>
|
||||
{notice ? <span className="truncate text-error">{notice}</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-gutter-md">
|
||||
<span className="flex items-center gap-2">
|
||||
<button type="button" title="Уменьшить" onClick={() => updateSettings({ zoom: Math.max(70, zoom - 10) })}>
|
||||
<Icon name="zoom_out" size={14} />
|
||||
</button>
|
||||
<span className="text-on-surface">{zoom}%</span>
|
||||
<button type="button" title="Увеличить" onClick={() => updateSettings({ zoom: Math.min(180, zoom + 10) })}>
|
||||
<Icon name="zoom_in" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HostResult } from '@shared/host'
|
||||
import { Icon } from './Icon'
|
||||
import { MenuBar } from './MenuBar'
|
||||
import type { InsertKind } from '../documents'
|
||||
import { useSession, type EditorMode } from '../session'
|
||||
import { useSettings } from '../settings'
|
||||
|
||||
const MODES: Array<{ id: EditorMode; icon: string; title: string }> = [
|
||||
{ id: 'code', icon: 'code', title: 'Код' },
|
||||
{ id: 'notion', icon: 'view_agenda', title: 'Блоки' },
|
||||
]
|
||||
|
||||
const INSERTS: Array<{ icon: string; label: string; title: string; kind: InsertKind }> = [
|
||||
{ icon: 'title', label: 'Введение', title: 'Вставить введение', kind: 'intro' },
|
||||
{ icon: 'table_chart', label: 'Таблица', title: 'Вставить таблицу', kind: 'table' },
|
||||
{ icon: 'image', label: 'Рисунок', title: 'Вставить рисунок', kind: 'figure' },
|
||||
{ icon: 'functions', label: '$$', title: 'Вставить формулу', kind: 'formula' },
|
||||
{ icon: 'account_tree', label: 'Схема', title: 'Вставить схему', kind: 'diagram' },
|
||||
{ icon: 'format_quote', label: 'Ссылка', title: 'Вставить ссылку на источник', kind: 'ref' },
|
||||
{ icon: 'insert_page_break', label: 'Разрыв', title: 'Вставить разрыв страницы', kind: 'break' },
|
||||
]
|
||||
|
||||
export function TitleBar() {
|
||||
const { mode, setMode, text, files, activeId, setNotice, insertSnippet } = useSession()
|
||||
const { settings, updateSettings } = useSettings()
|
||||
|
||||
async function call(task: () => Promise<HostResult>): Promise<void> {
|
||||
try {
|
||||
const result = await task()
|
||||
setNotice(result.ok ? null : result.error)
|
||||
} catch {
|
||||
setNotice('не подключено')
|
||||
}
|
||||
}
|
||||
|
||||
const mac = window.frame.platform === 'darwin'
|
||||
|
||||
return (
|
||||
<header className="flex flex-col bg-surface-container-low shadow-[0_1px_8px_rgba(0,0,0,0.5)]">
|
||||
<div className="flex h-window-bar-height items-stretch bg-surface-container-lowest">
|
||||
{mac ? <MacControls /> : null}
|
||||
<div
|
||||
className="drag-region flex min-w-0 flex-1 items-center gap-gutter-md px-gutter-md"
|
||||
onDoubleClick={(event) => {
|
||||
if ((event.target as HTMLElement).closest('.no-drag')) return
|
||||
void window.frame.toggleMaximize()
|
||||
}}
|
||||
>
|
||||
<MenuBar />
|
||||
|
||||
<div className="no-drag ml-auto flex h-7 shrink-0 items-center gap-2">
|
||||
<div className="flex h-7 items-center rounded bg-surface-container-high p-0.5">
|
||||
{MODES.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={item.title}
|
||||
onClick={() => setMode(item.id)}
|
||||
className={
|
||||
mode === item.id
|
||||
? 'flex h-6 items-center gap-1 rounded bg-primary-container px-2 font-code text-label-sm text-on-primary-container'
|
||||
: 'flex h-6 items-center gap-1 rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-variant hover:text-on-surface'
|
||||
}
|
||||
>
|
||||
<Icon name={item.icon} size={14} />
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<IconToggle
|
||||
on={settings.split}
|
||||
title={settings.split ? 'Закрыть предпросмотр' : 'Разделить экран'}
|
||||
icon="vertical_split"
|
||||
onClick={() => updateSettings({ split: !settings.split })}
|
||||
/>
|
||||
<IconToggle
|
||||
on={settings.split && settings.syncScroll}
|
||||
title={settings.split && settings.syncScroll ? 'Выключить синхронную прокрутку' : 'Синхронная прокрутка'}
|
||||
icon="sync"
|
||||
onClick={() => {
|
||||
if (!settings.split) updateSettings({ split: true, syncScroll: true })
|
||||
else updateSettings({ syncScroll: !settings.syncScroll })
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Открыть страницу в WordCraft"
|
||||
onClick={() =>
|
||||
void call(() =>
|
||||
window.host.edit(
|
||||
Object.values(files).map((file) => ({ id: file.id, text: file.id === activeId ? text : file.text })),
|
||||
activeId,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="flex h-6 items-center gap-1 rounded bg-surface-container-high px-2 font-code text-label-sm text-on-surface hover:bg-surface-variant"
|
||||
>
|
||||
<Icon name="article" size={13} />
|
||||
WordCraft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void call(() => window.host.build(text))}
|
||||
className="flex h-6 items-center gap-1 rounded bg-primary-container px-2 font-code text-label-sm font-semibold text-on-primary-container hover:bg-primary-fixed"
|
||||
>
|
||||
<Icon name="check_circle" size={13} />
|
||||
Собрать DOCX
|
||||
</button>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-on-primary">
|
||||
<Icon name="person" size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mac ? null : <WinControls />}
|
||||
</div>
|
||||
|
||||
<div className="flex h-9 items-center gap-gutter-md px-gutter-md">
|
||||
<div className="no-drag flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{INSERTS.map((item, index) => (
|
||||
<span key={item.label} className="flex items-center">
|
||||
{index === 1 || index === 4 ? <span className="mx-1 h-4 w-px shrink-0 bg-outline-variant" /> : null}
|
||||
<button
|
||||
type="button"
|
||||
title={item.title}
|
||||
onClick={() => insertSnippet(item.kind)}
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
>
|
||||
<Icon name={item.icon} size={14} />
|
||||
<span className="leading-none">{item.label}</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function IconToggle({
|
||||
on,
|
||||
title,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
on: boolean
|
||||
title: string
|
||||
icon: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-pressed={on}
|
||||
onClick={onClick}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded ${
|
||||
on ? 'bg-surface-container-high text-primary' : 'text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MacControls() {
|
||||
return (
|
||||
<div className="no-drag flex items-center gap-2 pl-gutter-md">
|
||||
<TrafficLight color="bg-[#ff5f57]" title="Закрыть" onClick={() => void window.frame.close()} />
|
||||
<TrafficLight color="bg-[#febc2e]" title="Свернуть" onClick={() => void window.frame.minimize()} />
|
||||
<TrafficLight
|
||||
color="bg-[#28c840]"
|
||||
title="Развернуть"
|
||||
onClick={() => void window.frame.toggleMaximize()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrafficLight({ color, title, onClick }: { color: string; title: string; onClick: () => void }) {
|
||||
return <button type="button" title={title} onClick={onClick} className={`h-3 w-3 rounded-full ${color}`} />
|
||||
}
|
||||
|
||||
function WinControls() {
|
||||
return (
|
||||
<div className="no-drag flex h-full text-on-surface">
|
||||
<CaptionButton title="Свернуть" onClick={() => void window.frame.minimize()}>
|
||||
<span className="block h-px w-2.5 bg-current" />
|
||||
</CaptionButton>
|
||||
<CaptionButton title="Развернуть" onClick={() => void window.frame.toggleMaximize()}>
|
||||
<span className="block h-2.5 w-2.5 border border-current" />
|
||||
</CaptionButton>
|
||||
<CaptionButton title="Закрыть" close onClick={() => void window.frame.close()}>
|
||||
<span className="relative block h-2.5 w-2.5">
|
||||
<span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 rotate-45 bg-current" />
|
||||
<span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 -rotate-45 bg-current" />
|
||||
</span>
|
||||
</CaptionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CaptionButton({
|
||||
title,
|
||||
onClick,
|
||||
close = false,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
onClick: () => void
|
||||
close?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={
|
||||
close
|
||||
? 'flex h-full w-[46px] items-center justify-center hover:bg-[#e81123] hover:text-white'
|
||||
: 'flex h-full w-[46px] items-center justify-center hover:bg-white/10'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { forwardRef, type ReactNode } from 'react'
|
||||
import type { WorkspaceFile } from '../documents'
|
||||
import { previewBlocks, type PreviewBlock } from '../preview'
|
||||
import {
|
||||
diagramLayout,
|
||||
isDiagramLang,
|
||||
parseBib,
|
||||
parseDiagram,
|
||||
parseYamlPreview,
|
||||
previewKind,
|
||||
resolveWorkspacePath,
|
||||
type DiagramModel,
|
||||
type DiagramNode,
|
||||
type PreviewKind,
|
||||
} from '../previewFile'
|
||||
import { useSettings, type PreviewMargin } from '../settings'
|
||||
|
||||
const MARGINS: Record<PreviewMargin, string> = {
|
||||
narrow: '12mm',
|
||||
normal: '20mm',
|
||||
wide: '28mm',
|
||||
}
|
||||
|
||||
const LABELS: Record<PreviewKind, string> = {
|
||||
markdown: 'Предпросмотр',
|
||||
svg: 'Рисунок',
|
||||
yaml: 'Поля',
|
||||
bib: 'Источники',
|
||||
diagram: 'Схема',
|
||||
text: 'Текст',
|
||||
}
|
||||
|
||||
type WordPreviewProps = {
|
||||
text: string
|
||||
fileName: string
|
||||
fileId: string
|
||||
files: Record<string, WorkspaceFile>
|
||||
onScroll: () => void
|
||||
}
|
||||
|
||||
export const WordPreview = forwardRef<HTMLDivElement, WordPreviewProps>(function WordPreview(
|
||||
{ text, fileName, fileId, files, onScroll },
|
||||
ref,
|
||||
) {
|
||||
const kind = previewKind(fileName, text)
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col border-l border-outline-variant/50 bg-[#1a2438]">
|
||||
<div className="flex h-7 shrink-0 items-center gap-2 px-3 font-code text-label-sm text-on-surface-variant">
|
||||
<span>{LABELS[kind]}</span>
|
||||
{fileName ? <span className="truncate text-outline">{fileName}</span> : null}
|
||||
</div>
|
||||
<div ref={ref} onScroll={onScroll} className="min-h-0 flex-1 overflow-auto px-4 py-6">
|
||||
{kind === 'svg' ? <SvgSheet text={text} /> : null}
|
||||
{kind === 'yaml' ? <YamlSheet text={text} /> : null}
|
||||
{kind === 'bib' ? <BibSheet text={text} /> : null}
|
||||
{kind === 'diagram' ? <DiagramSheet text={text} /> : null}
|
||||
{kind === 'text' ? <TextSheet text={text} /> : null}
|
||||
{kind === 'markdown' ? <MarkdownSheet text={text} fileId={fileId} files={files} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
|
||||
function Sheet({ children, mono = false }: { children: ReactNode; mono?: boolean }) {
|
||||
const { settings } = useSettings()
|
||||
return (
|
||||
<article
|
||||
className="mx-auto min-h-[297mm] bg-white text-black shadow-[0_8px_28px_rgba(0,0,0,0.35)]"
|
||||
style={{
|
||||
width: 'min(180mm, 100%)',
|
||||
padding: MARGINS[settings.previewMargin],
|
||||
fontFamily: mono ? '"JetBrains Mono", ui-monospace, monospace' : '"Times New Roman", Times, serif',
|
||||
fontSize: `${settings.previewFontSize}px`,
|
||||
lineHeight: settings.previewSpacing / 100,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function SvgSheet({ text }: { text: string }) {
|
||||
const svg = text.trim()
|
||||
return (
|
||||
<Sheet>
|
||||
{svg.startsWith('<svg') ? (
|
||||
<div className="flex min-h-[120mm] items-center justify-center [&_svg]:h-auto [&_svg]:max-h-[160mm] [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
) : (
|
||||
<p className="text-[#8a8a8a]">В файле нет рисунка SVG</p>
|
||||
)}
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function YamlSheet({ text }: { text: string }) {
|
||||
const rows = parseYamlPreview(text)
|
||||
return (
|
||||
<Sheet>
|
||||
{rows.length === 0 ? <p className="text-[#8a8a8a]">Пустой файл</p> : null}
|
||||
{rows.map((row, index) =>
|
||||
row.note ? (
|
||||
<p key={index} className="text-[#666] italic" style={{ paddingLeft: `${row.depth * 0.6}em` }}>
|
||||
{row.value}
|
||||
</p>
|
||||
) : (
|
||||
<p key={index} className="my-[0.15em]" style={{ paddingLeft: `${row.depth * 0.6}em` }}>
|
||||
{row.key ? <span className="font-bold">{row.key}</span> : null}
|
||||
{row.key && row.value ? <span>: </span> : null}
|
||||
{row.value}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function BibSheet({ text }: { text: string }) {
|
||||
const entries = parseBib(text)
|
||||
return (
|
||||
<Sheet>
|
||||
{entries.length === 0 ? <p className="text-[#8a8a8a]">В файле нет записей</p> : null}
|
||||
{entries.map((entry) => {
|
||||
const title = entry.fields.find((field) => field.key === 'title')?.value ?? entry.id
|
||||
const author = entry.fields.find((field) => field.key === 'author')?.value
|
||||
return (
|
||||
<section key={`${entry.type}:${entry.id}`} className="mb-[1em]">
|
||||
<p className="mb-[0.15em] text-[0.8em] tracking-wide text-[#555] uppercase">{entry.type}</p>
|
||||
<p className="font-bold">{title}</p>
|
||||
{author ? <p>{author}</p> : null}
|
||||
<p className="text-[0.9em] text-[#333]">
|
||||
{entry.fields
|
||||
.filter((field) => field.key !== 'title' && field.key !== 'author')
|
||||
.map((field) => `${field.key}: ${field.value}`)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function TextSheet({ text }: { text: string }) {
|
||||
return (
|
||||
<Sheet mono>
|
||||
{text.trim() ? <pre className="font-mono whitespace-pre-wrap">{text}</pre> : <p className="text-[#8a8a8a]">Пустой файл</p>}
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function DiagramSheet({ text }: { text: string }) {
|
||||
const model = parseDiagram(text)
|
||||
return (
|
||||
<Sheet>
|
||||
<DiagramView model={model} source={text} />
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function DiagramView({ model, source }: { model: DiagramModel; source: string }) {
|
||||
if (model.nodes.length === 0) {
|
||||
return <pre className="font-mono text-[0.85em] whitespace-pre-wrap">{source.trim() || 'Пустая схема'}</pre>
|
||||
}
|
||||
const layout = diagramLayout(model)
|
||||
return (
|
||||
<div>
|
||||
{model.title ? <p className="mb-[0.8em] text-center font-bold">{model.title}</p> : null}
|
||||
{layout === 'sequence' ? <SequenceDiagram model={model} /> : null}
|
||||
{layout === 'entities' ? <EntityDiagram model={model} /> : null}
|
||||
{layout === 'blocks' ? <BlockDiagram model={model} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function nodeName(model: DiagramModel, id: string): string {
|
||||
return model.nodes.find((node) => node.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
function SequenceDiagram({ model }: { model: DiagramModel }) {
|
||||
const columns = model.nodes.filter((node) => node.kind === 'actor' || node.kind === 'participant' || node.kind === 'database')
|
||||
const index = new Map(columns.map((node, place) => [node.id, place]))
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid gap-x-3" style={{ gridTemplateColumns: `repeat(${Math.max(columns.length, 1)}, minmax(6.5em, 1fr))` }}>
|
||||
{columns.map((node) => (
|
||||
<div key={node.id} className="border border-[#222] px-[0.3em] py-[0.45em] text-center text-[0.9em]">
|
||||
{node.label}
|
||||
</div>
|
||||
))}
|
||||
{model.edges.map((edge, edgeIndex) => {
|
||||
const from = index.get(edge.from) ?? 0
|
||||
const to = index.get(edge.to) ?? from
|
||||
const start = Math.min(from, to) + 1
|
||||
const span = Math.abs(to - from) + 1
|
||||
return (
|
||||
<div
|
||||
key={edgeIndex}
|
||||
className="mt-[0.35em] border-b border-[#222] pb-[0.15em] text-center text-[0.8em]"
|
||||
style={{ gridColumn: `${start} / span ${span}`, gridRow: edgeIndex + 2 }}
|
||||
>
|
||||
{edge.label} {to >= from ? '→' : '←'}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EntityDiagram({ model }: { model: DiagramModel }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap justify-center gap-[1em]">
|
||||
{model.nodes
|
||||
.filter((node) => node.kind === 'entity')
|
||||
.map((node) => (
|
||||
<table key={node.id} className="border-collapse">
|
||||
<caption className="border border-[#222] bg-[#f3f3f3] px-[0.6em] py-[0.2em] font-bold">{node.label}</caption>
|
||||
<tbody>
|
||||
{node.fields.map((field) => (
|
||||
<tr key={field}>
|
||||
<td className="border border-[#222] px-[0.5em] py-[0.1em]">{field}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</div>
|
||||
<EdgeList model={model} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockDiagram({ model }: { model: DiagramModel }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-center gap-[1em]">
|
||||
{model.nodes.map((node) => (
|
||||
<BlockCard key={node.id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
<EdgeList model={model} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockCard({ node }: { node: DiagramNode }) {
|
||||
const rounded = node.kind === 'usecase' || node.kind === 'person' || node.kind === 'actor'
|
||||
return (
|
||||
<div
|
||||
className="min-w-[8em] max-w-[14em] border border-[#222] px-[0.7em] py-[0.45em] text-center"
|
||||
style={{ borderRadius: rounded ? '999px' : '2px', background: node.kind === 'external' ? '#f7f7f7' : '#fff' }}
|
||||
>
|
||||
<div className="text-[0.75em] tracking-wide text-[#555] uppercase">{kindLabel(node.kind)}</div>
|
||||
<div className="font-bold">{node.label}</div>
|
||||
{node.detail ? <div className="text-[0.85em]">{node.detail}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function kindLabel(kind: DiagramNode['kind']): string {
|
||||
if (kind === 'person' || kind === 'actor') return 'человек'
|
||||
if (kind === 'external') return 'внешняя'
|
||||
if (kind === 'database') return 'хранилище'
|
||||
if (kind === 'usecase') return 'вариант'
|
||||
if (kind === 'entity') return 'сущность'
|
||||
if (kind === 'participant') return 'участник'
|
||||
return 'система'
|
||||
}
|
||||
|
||||
function EdgeList({ model }: { model: DiagramModel }) {
|
||||
if (model.edges.length === 0) return null
|
||||
return (
|
||||
<ul className="mt-[1em] list-none pl-0">
|
||||
{model.edges.map((edge, index) => (
|
||||
<li key={index} className="my-[0.2em] text-center">
|
||||
{nodeName(model, edge.from)} → {nodeName(model, edge.to)}
|
||||
{edge.label ? ` — ${edge.label}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownSheet({
|
||||
text,
|
||||
fileId,
|
||||
files,
|
||||
}: {
|
||||
text: string
|
||||
fileId: string
|
||||
files: Record<string, WorkspaceFile>
|
||||
}) {
|
||||
const blocks = previewBlocks(text)
|
||||
return (
|
||||
<Sheet>
|
||||
{blocks.length === 0 ? <p className="text-[#8a8a8a]">Пустой документ</p> : null}
|
||||
{blocks.map((block, index) => (
|
||||
<MarkdownBlock key={index} block={block} fileId={fileId} files={files} />
|
||||
))}
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownBlock({
|
||||
block,
|
||||
fileId,
|
||||
files,
|
||||
}: {
|
||||
block: PreviewBlock
|
||||
fileId: string
|
||||
files: Record<string, WorkspaceFile>
|
||||
}) {
|
||||
if (block.type === 'h') {
|
||||
const size = block.level === 1 ? '1.35em' : block.level === 2 ? '1.15em' : '1.05em'
|
||||
return (
|
||||
<h2 className="mt-[0.8em] mb-[0.35em] font-bold" style={{ fontSize: size, textAlign: block.level === 1 ? 'center' : 'left' }}>
|
||||
<Rich text={block.text} />
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
if (block.type === 'ul' || block.type === 'ol') {
|
||||
const Tag = block.type === 'ul' ? 'ul' : 'ol'
|
||||
return (
|
||||
<Tag className="my-[0.4em] list-outside pl-[1.4em]">
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<li key={itemIndex} className={block.type === 'ul' ? 'list-disc' : 'list-decimal'}>
|
||||
<Rich text={item} />
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
if (block.type === 'quote') {
|
||||
return (
|
||||
<blockquote className="my-[0.5em] border-l-2 border-[#8a8a8a] pl-[0.8em] text-[#333] italic">
|
||||
<Rich text={block.text} />
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
if (block.type === 'code' && isDiagramLang(block.lang)) {
|
||||
return (
|
||||
<div className="my-[0.7em]">
|
||||
<DiagramView model={parseDiagram(block.text)} source={block.text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (block.type === 'code') {
|
||||
return <pre className="my-[0.6em] overflow-x-auto bg-[#f3f3f3] p-[0.6em] font-mono text-[0.85em] whitespace-pre-wrap">{block.text}</pre>
|
||||
}
|
||||
if (block.type === 'table') {
|
||||
return (
|
||||
<table className="my-[0.6em] w-full border-collapse">
|
||||
<tbody>
|
||||
{block.rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="border border-[#333] px-[0.4em] py-[0.15em]" style={{ fontWeight: rowIndex === 0 ? 700 : 400 }}>
|
||||
<Rich text={cell} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
if (block.type === 'formula') return <p className="my-[0.6em] text-center">{block.text}</p>
|
||||
if (block.type === 'caption') {
|
||||
return (
|
||||
<p className="mt-[0.7em] mb-[0.2em] text-center font-bold">
|
||||
<Rich text={block.text} />
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (block.type === 'figure') return <FigureBlock alt={block.alt} src={block.src} fileId={fileId} files={files} />
|
||||
if (block.type === 'break') return <hr className="my-[1em] border-dashed border-[#bdbdbd]" />
|
||||
return (
|
||||
<p className="my-[0.35em] text-justify" style={{ textIndent: '1.25cm' }}>
|
||||
<Rich text={block.text} />
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function FigureBlock({
|
||||
alt,
|
||||
src,
|
||||
fileId,
|
||||
files,
|
||||
}: {
|
||||
alt: string
|
||||
src: string
|
||||
fileId: string
|
||||
files: Record<string, WorkspaceFile>
|
||||
}) {
|
||||
const linked = files[resolveWorkspacePath(fileId, src)]
|
||||
const svg = linked?.text.trimStart().startsWith('<svg') ? linked.text : src.trimStart().startsWith('<svg') ? src : ''
|
||||
return (
|
||||
<figure className="my-[0.7em] text-center">
|
||||
{svg ? (
|
||||
<div className="mx-auto max-w-full [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[80mm] [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
) : (
|
||||
<div className="mx-auto flex h-[4.5em] w-[10em] items-center justify-center border border-[#bdbdbd] text-[0.85em] text-[#666]">рисунок</div>
|
||||
)}
|
||||
<figcaption className="mt-[0.3em]">{alt}</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
function Rich({ text }: { text: string }) {
|
||||
const chunks = text.split(
|
||||
/(\*\*[^*]+?\*\*|\*[^*]+?\*|`[^`]+`|\[[^\]]+?\]\([^)]+?\)|@(?:Рисунок|Таблица|Листинг|Формула):[A-Za-z0-9_-]+)/g,
|
||||
)
|
||||
return chunks.map((chunk, index) => {
|
||||
if (chunk.startsWith('**') && chunk.endsWith('**') && chunk.length > 4) return <strong key={index}>{chunk.slice(2, -2)}</strong>
|
||||
if (chunk.startsWith('*') && chunk.endsWith('*') && chunk.length > 2) return <em key={index}>{chunk.slice(1, -1)}</em>
|
||||
if (chunk.startsWith('`') && chunk.endsWith('`') && chunk.length > 2) {
|
||||
return (
|
||||
<code key={index} className="font-mono text-[0.9em]">
|
||||
{chunk.slice(1, -1)}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
if (chunk.startsWith('@')) {
|
||||
return (
|
||||
<span key={index} className="text-[#0563c1]">
|
||||
{chunk}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const link = /^\[([^\]]+)\]\([^)]+\)$/.exec(chunk)
|
||||
if (link) {
|
||||
return (
|
||||
<span key={index} className="text-[#0563c1] underline">
|
||||
{link[1]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span key={index}>{chunk}</span>
|
||||
})
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module '*.css' {}
|
||||
@@ -0,0 +1,370 @@
|
||||
export type WorkspaceFile = {
|
||||
id: string
|
||||
name: string
|
||||
dir: string[]
|
||||
icon: string
|
||||
tone: string
|
||||
text: string
|
||||
initial: string
|
||||
}
|
||||
|
||||
export type InsertKind = 'intro' | 'table' | 'figure' | 'formula' | 'diagram' | 'ref' | 'break'
|
||||
|
||||
export const ACTIVE_FILE_ID = 'mirea/coursework_report.md'
|
||||
|
||||
const COURSEWORK = `# *СОДЕРЖАНИЕ
|
||||
[TOC]
|
||||
|
||||
# *ВВЕДЕНИЕ
|
||||
|
||||
В настоящей работе рассматривается задача построения информационной системы учёта. Цель работы — спроектировать архитектуру и реализовать прототип модуля обработки данных.
|
||||
|
||||
# Анализ предметной области
|
||||
|
||||
Предметная область связана с обработкой структурированных данных. Обзор подходов приведён в работах [1], [2].
|
||||
|
||||
Структура системы показана на @Рисунок:arch.
|
||||
|
||||

|
||||
|
||||
Сравнение вариантов реализации представлено в @Таблица:cmp.
|
||||
|
||||
%cmp Сравнение подходов
|
||||
|
||||
| Подход | Сложность | Гибкость |
|
||||
|--------|-----------|----------|
|
||||
| Монолит | низкая | низкая |
|
||||
| Модульный | средняя | высокая |
|
||||
| Микросервисы | высокая | высокая |
|
||||
|
||||
Основные требования к системе:
|
||||
|
||||
- поддержка импорта данных;
|
||||
- формирование отчётности;
|
||||
- разграничение прав доступа.
|
||||
|
||||
Порядок разработки включает следующие этапы:
|
||||
|
||||
1. анализ требований;
|
||||
2. проектирование архитектуры;
|
||||
3. реализация прототипа.
|
||||
|
||||
# Проектирование
|
||||
|
||||
Ключевое соотношение для оценки объёма данных задаётся формулой:
|
||||
|
||||
%eq1
|
||||
$$
|
||||
V = N \\times S
|
||||
$$
|
||||
|
||||
где $N$ — число записей, $S$ — средний размер записи. Использование зависимости приведено в (@Формула:eq1).
|
||||
|
||||
Фрагмент реализации:
|
||||
|
||||
%code1 Процедура расчёта объёма
|
||||
|
||||
\`\`\`python
|
||||
def volume(n: int, s: float) -> float:
|
||||
return n * s
|
||||
\`\`\`
|
||||
|
||||
Пример вызова приведён в @Листинг:code1.
|
||||
|
||||
Диаграмма контейнеров лежит в файле schemas/uml_c4.puml, модель данных — в schemas/er.puml.
|
||||
|
||||
# *ЗАКЛЮЧЕНИЕ
|
||||
|
||||
В работе выполнен анализ предметной области, спроектирована архитектура и реализован прототип модуля расчёта.
|
||||
|
||||
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
|
||||
[1]: Иванов И. И. Информационные системы. — М.: Наука, 2023. — 120 с.
|
||||
[2]: Петров П. П. Базы данных: учебник. — СПб.: Питер, 2022. — 200 с.
|
||||
[3]: ГОСТ 7.32-2017. Отчет о научно-исследовательской работе. — М., 2022.
|
||||
[4]: Сидоров С. С. Разработка на Python. — М.: ДМК Пресс, 2024. — 300 с.
|
||||
[5]: Козлов К. К. Анализ данных. — М.: Бином, 2022. — 150 с.
|
||||
[6]: Смирнова А. А. Проектирование ПО. — М.: Инфра-М, 2023. — 180 с.
|
||||
|
||||
# *ПРИЛОЖЕНИЯ
|
||||
|
||||
## Приложение А Исходный код модуля
|
||||
|
||||
Дополнительный код модуля вынесен в приложение.
|
||||
`
|
||||
|
||||
const EXAMPLE = `# *ВВЕДЕНИЕ
|
||||
|
||||
Краткий пример отчёта. Его можно править отдельно от курсовой: у каждого файла свой текст.
|
||||
|
||||
# 1 Постановка задачи
|
||||
|
||||
Нужно описать модуль и сослаться на источник [1].
|
||||
|
||||
%table1 Состав модуля
|
||||
|
||||
| Часть | Назначение |
|
||||
|-------|------------|
|
||||
| Интерфейс | ввод данных |
|
||||
| Расчёт | обработка |
|
||||
|
||||
# *ЗАКЛЮЧЕНИЕ
|
||||
|
||||
Пример показывает разделы, таблицу и ссылку на источник.
|
||||
|
||||
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
|
||||
[1]: ГОСТ 7.32-2017. Отчет о научно-исследовательской работе. — М., 2022.
|
||||
`
|
||||
|
||||
const SPEC = `# Черновик профиля отчёта.
|
||||
# Сборка DOCX этот файл не читает: ядро не подключено.
|
||||
# Поля совпадают с тем, что позже уйдёт в спецификацию профиля.
|
||||
|
||||
profile: coursework
|
||||
document: Курсовая работа
|
||||
standard: ГОСТ 7.32
|
||||
language: ru
|
||||
|
||||
institute: МИРЭА
|
||||
department: Кафедра
|
||||
discipline: Проектирование информационных систем
|
||||
supervisor: Иванов И. И.
|
||||
|
||||
numbering:
|
||||
figures: по разделам
|
||||
tables: по разделам
|
||||
formulas: сквозная для формул со ссылкой
|
||||
|
||||
sections:
|
||||
- "*СОДЕРЖАНИЕ"
|
||||
- "*ВВЕДЕНИЕ"
|
||||
- Анализ предметной области
|
||||
- Проектирование
|
||||
- "*ЗАКЛЮЧЕНИЕ"
|
||||
- "*СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"
|
||||
- "*ПРИЛОЖЕНИЯ"
|
||||
`
|
||||
|
||||
const INFO = `# Данные титульного листа рядом с отчётом.
|
||||
# ФИО и группа задаются в настройках окна, не здесь.
|
||||
|
||||
work_number: "0000"
|
||||
year: 2026
|
||||
institute: МИРЭА
|
||||
department: Кафедра
|
||||
discipline: Проектирование информационных систем
|
||||
teacher: Иванов И. И.
|
||||
theme: Информационная система учёта
|
||||
`
|
||||
|
||||
const BIB = `@book{ivanov2023,
|
||||
author = {Иванов, И. И.},
|
||||
title = {Информационные системы},
|
||||
publisher = {Наука},
|
||||
address = {М.},
|
||||
year = {2023},
|
||||
pages = {120}
|
||||
}
|
||||
|
||||
@book{petrov2022,
|
||||
author = {Петров, П. П.},
|
||||
title = {Базы данных},
|
||||
publisher = {Питер},
|
||||
address = {СПб.},
|
||||
year = {2022},
|
||||
pages = {200}
|
||||
}
|
||||
|
||||
@standard{gost732,
|
||||
title = {ГОСТ 7.32-2017. Отчет о научно-исследовательской работе},
|
||||
address = {М.},
|
||||
year = {2022}
|
||||
}
|
||||
`
|
||||
|
||||
const C4 = `@startuml
|
||||
!include <C4/C4_Context>
|
||||
title Контекст информационной системы учёта
|
||||
|
||||
Person(user, "Пользователь", "Ведёт учёт")
|
||||
System(app, "Система учёта", "Принимает данные и строит отчёт")
|
||||
System_Ext(store, "Хранилище", "База данных")
|
||||
|
||||
Rel(user, app, "Вводит записи")
|
||||
Rel(app, store, "Читает и пишет")
|
||||
@enduml
|
||||
`
|
||||
|
||||
const ER = `@startuml
|
||||
entity "Запись" as row {
|
||||
* id
|
||||
--
|
||||
created_at
|
||||
payload
|
||||
}
|
||||
|
||||
entity "Пользователь" as person {
|
||||
* id
|
||||
--
|
||||
name
|
||||
}
|
||||
|
||||
person ||--o{ row : создаёт
|
||||
@enduml
|
||||
`
|
||||
|
||||
const SEQUENCE = `@startuml
|
||||
actor Пользователь
|
||||
participant Интерфейс
|
||||
participant Расчёт
|
||||
database Хранилище
|
||||
|
||||
Пользователь -> Интерфейс : ввод
|
||||
Интерфейс -> Расчёт : volume(n, s)
|
||||
Расчёт --> Интерфейс : V
|
||||
Интерфейс -> Хранилище : сохранить
|
||||
@enduml
|
||||
`
|
||||
|
||||
const CONTEXT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="420" height="180" viewBox="0 0 420 180">
|
||||
<rect x="20" y="50" width="140" height="70" rx="8" fill="#e8f6ff" stroke="#0b1326"/>
|
||||
<text x="90" y="90" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#0b1326">Пользователь</text>
|
||||
<rect x="250" y="40" width="150" height="90" rx="8" fill="#d7f0ff" stroke="#0b1326"/>
|
||||
<text x="325" y="90" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#0b1326">Система учёта</text>
|
||||
<line x1="160" y1="85" x2="250" y2="85" stroke="#0b1326" marker-end="url(#arrow)"/>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#0b1326"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
`
|
||||
|
||||
const CONTAINER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="200" viewBox="0 0 440 200">
|
||||
<rect x="16" y="16" width="408" height="168" rx="10" fill="#f4f8fc" stroke="#0b1326"/>
|
||||
<text x="32" y="40" font-family="sans-serif" font-size="13" fill="#0b1326">Система учёта</text>
|
||||
<rect x="40" y="60" width="150" height="90" rx="6" fill="#ffffff" stroke="#0b1326"/>
|
||||
<text x="115" y="110" text-anchor="middle" font-family="sans-serif" font-size="13" fill="#0b1326">Интерфейс</text>
|
||||
<rect x="240" y="60" width="150" height="90" rx="6" fill="#ffffff" stroke="#0b1326"/>
|
||||
<text x="315" y="110" text-anchor="middle" font-family="sans-serif" font-size="13" fill="#0b1326">Расчёт</text>
|
||||
</svg>
|
||||
`
|
||||
|
||||
function file(
|
||||
id: string,
|
||||
dir: string[],
|
||||
name: string,
|
||||
icon: string,
|
||||
tone: string,
|
||||
text: string,
|
||||
): WorkspaceFile {
|
||||
return { id, name, dir, icon, tone, text, initial: text }
|
||||
}
|
||||
|
||||
export function seedFiles(): Record<string, WorkspaceFile> {
|
||||
return {
|
||||
[ACTIVE_FILE_ID]: file(ACTIVE_FILE_ID, ['mirea'], 'coursework_report.md', 'description', 'text-primary', COURSEWORK),
|
||||
'mirea/mirea_spec.yaml': file('mirea/mirea_spec.yaml', ['mirea'], 'mirea_spec.yaml', 'data_object', 'text-secondary', SPEC),
|
||||
'mirea/info_conv.yaml': file('mirea/info_conv.yaml', ['mirea'], 'info_conv.yaml', 'data_object', 'text-secondary', INFO),
|
||||
'mirea/bibliography.bib': file('mirea/bibliography.bib', ['mirea'], 'bibliography.bib', 'menu_book', 'text-tertiary', BIB),
|
||||
'mirea/schemas/uml_c4.puml': file(
|
||||
'mirea/schemas/uml_c4.puml',
|
||||
['mirea', 'schemas'],
|
||||
'uml_c4.puml',
|
||||
'account_tree',
|
||||
'text-primary',
|
||||
C4,
|
||||
),
|
||||
'mirea/schemas/er.puml': file('mirea/schemas/er.puml', ['mirea', 'schemas'], 'er.puml', 'account_tree', 'text-primary', ER),
|
||||
'mirea/schemas/sequence.puml': file(
|
||||
'mirea/schemas/sequence.puml',
|
||||
['mirea', 'schemas'],
|
||||
'sequence.puml',
|
||||
'account_tree',
|
||||
'text-primary',
|
||||
SEQUENCE,
|
||||
),
|
||||
'mirea/figures/context.svg': file(
|
||||
'mirea/figures/context.svg',
|
||||
['mirea', 'figures'],
|
||||
'context.svg',
|
||||
'image',
|
||||
'text-primary-fixed-dim',
|
||||
CONTEXT_SVG,
|
||||
),
|
||||
'mirea/figures/container.svg': file(
|
||||
'mirea/figures/container.svg',
|
||||
['mirea', 'figures'],
|
||||
'container.svg',
|
||||
'image',
|
||||
'text-primary-fixed-dim',
|
||||
CONTAINER_SVG,
|
||||
),
|
||||
'example.md': file('example.md', [], 'example.md', 'description', 'text-primary', EXAMPLE),
|
||||
}
|
||||
}
|
||||
|
||||
export function folderIds(dir: string[]): string[] {
|
||||
const ids: string[] = []
|
||||
for (let index = 0; index < dir.length; index += 1) {
|
||||
ids.push(`dir:${dir.slice(0, index + 1).join('/')}`)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function makeSnippet(kind: InsertKind, n: number): string {
|
||||
if (kind === 'intro') return '# *ВВЕДЕНИЕ\n\n'
|
||||
if (kind === 'table') {
|
||||
return `%table${n} Название таблицы\n\n| Показатель | Значение |\n|---|---|\n| | |\n\n`
|
||||
}
|
||||
if (kind === 'figure') return `\n\n`
|
||||
if (kind === 'formula') return `%eq${n}\n$$\nE = mc^2\n$$\n\n`
|
||||
if (kind === 'diagram') {
|
||||
return `%scheme${n} Название схемы\n\n\`\`\`plantuml\n@startuml\nactor Пользователь\nПользователь --> (Система)\n@enduml\n\`\`\`\n\n`
|
||||
}
|
||||
if (kind === 'ref') return '[1]'
|
||||
return '---\n\n'
|
||||
}
|
||||
|
||||
export function insertAtCursor(
|
||||
source: string,
|
||||
line: number,
|
||||
col: number,
|
||||
snippet: string,
|
||||
): { text: string; line: number } {
|
||||
const lines = source.split('\n')
|
||||
const index = Math.min(Math.max(lines.length - 1, 0), Math.max(0, line - 1))
|
||||
const current = lines[index] ?? ''
|
||||
const at = Math.min(current.length, Math.max(0, col - 1))
|
||||
const block = snippet.includes('\n')
|
||||
const chunk = `${block && current.slice(0, at).trim() !== '' ? '\n' : ''}${snippet}`
|
||||
lines[index] = current.slice(0, at) + chunk + current.slice(at)
|
||||
return { text: lines.join('\n'), line: index + chunk.split('\n').length }
|
||||
}
|
||||
|
||||
export function documentStats(text: string): { words: number; tables: number; figures: number; formulas: number } {
|
||||
const lines = text.split('\n')
|
||||
let tables = 0
|
||||
let figures = 0
|
||||
let formulas = 0
|
||||
let inMath = false
|
||||
let inFence = false
|
||||
for (const line of lines) {
|
||||
if (/^(```|~~~)/.test(line)) {
|
||||
if (!inFence && /^(```|~~~)(uml|plantuml|mermaid|mmd|idef0|dfd|c4|usecase)\b/i.test(line)) figures += 1
|
||||
inFence = !inFence
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '$$') {
|
||||
if (!inMath) formulas += 1
|
||||
inMath = !inMath
|
||||
continue
|
||||
}
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.includes('|') && trimmed.includes('-') && /^[\s|:-]+$/.test(trimmed)) tables += 1
|
||||
if (/!\[[^\]]*]\(/.test(line)) figures += 1
|
||||
}
|
||||
const words = text.trim() ? text.trim().split(/\s+/).length : 0
|
||||
return { words, tables, figures, formulas }
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from '../components/Icon'
|
||||
import {
|
||||
BLOCK_KINDS,
|
||||
createBlock,
|
||||
moveBlock,
|
||||
parseBlocks,
|
||||
serializeBlocks,
|
||||
type Block,
|
||||
type BlockType,
|
||||
} from './blocks'
|
||||
|
||||
type NotionEditorProps = {
|
||||
active: boolean
|
||||
text: string
|
||||
onChange: (text: string) => void
|
||||
}
|
||||
|
||||
type MenuState = { blockId: string; mode: 'insert' | 'convert' } | null
|
||||
|
||||
export function NotionEditor({ active, text, onChange }: NotionEditorProps) {
|
||||
const onChangeRef = useRef(onChange)
|
||||
const textRef = useRef(text)
|
||||
const opened = useRef(false)
|
||||
const owned = useRef(text)
|
||||
const [blocks, setBlocks] = useState<Block[]>(() => parseBlocks(text))
|
||||
const blocksRef = useRef(blocks)
|
||||
onChangeRef.current = onChange
|
||||
textRef.current = text
|
||||
blocksRef.current = blocks
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
opened.current = true
|
||||
if (serializeBlocks(blocksRef.current) !== text) setBlocks(parseBlocks(text))
|
||||
owned.current = text
|
||||
return
|
||||
}
|
||||
if (!opened.current) return
|
||||
const markdown = serializeBlocks(blocksRef.current)
|
||||
if (markdown !== owned.current) onChangeRef.current(markdown)
|
||||
}, [active, text])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const markdown = serializeBlocks(blocks)
|
||||
if (markdown !== textRef.current) onChangeRef.current(markdown)
|
||||
}, [active, blocks])
|
||||
|
||||
return (
|
||||
<div className={active ? 'notion-editor h-full overflow-auto' : 'hidden'}>
|
||||
<div className="mx-auto w-full max-w-[760px] px-16 py-10">
|
||||
<BlockStack blocks={blocks} onChange={setBlocks} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockStack({ blocks, onChange }: { blocks: Block[]; onChange: (blocks: Block[]) => void }) {
|
||||
const [menu, setMenu] = useState<MenuState>(null)
|
||||
|
||||
function update(id: string, patch: Partial<Block>) {
|
||||
onChange(blocks.map((block) => (block.id === id ? { ...block, ...patch } : block)))
|
||||
}
|
||||
|
||||
function insertAfter(id: string, type: BlockType) {
|
||||
const index = blocks.findIndex((block) => block.id === id)
|
||||
const next = blocks.slice()
|
||||
next.splice(index + 1, 0, createBlock(type))
|
||||
onChange(next)
|
||||
setMenu(null)
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
if (blocks.length === 1) {
|
||||
onChange([createBlock('paragraph')])
|
||||
return
|
||||
}
|
||||
onChange(blocks.filter((block) => block.id !== id))
|
||||
}
|
||||
|
||||
function choose(type: BlockType) {
|
||||
if (!menu) return
|
||||
if (menu.mode === 'convert') update(menu.blockId, { type })
|
||||
else insertAfter(menu.blockId, type)
|
||||
setMenu(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1" onClick={() => setMenu(null)}>
|
||||
{blocks.map((block) => (
|
||||
<BlockRow
|
||||
key={block.id}
|
||||
block={block}
|
||||
menu={menu?.blockId === block.id ? menu.mode : null}
|
||||
onText={(value) => update(block.id, { text: value })}
|
||||
onOpenMenu={(mode) => setMenu({ blockId: block.id, mode })}
|
||||
onChoose={choose}
|
||||
onEnter={() => insertAfter(block.id, block.type === 'bullet' ? 'bullet' : 'paragraph')}
|
||||
onDelete={() => remove(block.id)}
|
||||
onDrop={(fromId) => onChange(moveBlock(blocks, fromId, block.id))}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex h-9 items-center gap-2 rounded border border-dashed border-outline-variant px-3 font-body text-body-md text-on-surface-variant hover:border-primary hover:text-on-surface"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
const last = blocks[blocks.length - 1]
|
||||
insertAfter(last.id, 'paragraph')
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size={16} />
|
||||
Добавить блок
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockRow({
|
||||
block,
|
||||
menu,
|
||||
onText,
|
||||
onOpenMenu,
|
||||
onChoose,
|
||||
onEnter,
|
||||
onDelete,
|
||||
onDrop,
|
||||
}: {
|
||||
block: Block
|
||||
menu: 'insert' | 'convert' | null
|
||||
onText: (text: string) => void
|
||||
onOpenMenu: (mode: 'insert' | 'convert') => void
|
||||
onChoose: (type: BlockType) => void
|
||||
onEnter: () => void
|
||||
onDelete: () => void
|
||||
onDrop: (fromId: string) => void
|
||||
}) {
|
||||
const kind = BLOCK_KINDS.find((item) => item.type === block.type) ?? BLOCK_KINDS[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
const fromId = event.dataTransfer.getData('text/plain')
|
||||
if (fromId) onDrop(fromId)
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-1 -left-14 flex items-center gap-0.5 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
title="Вставить блок"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenMenu('insert')
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Перетащить"
|
||||
draggable
|
||||
className="flex h-6 w-6 cursor-grab items-center justify-center rounded text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData('text/plain', block.id)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
>
|
||||
<Icon name="drag_indicator" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded px-1 py-0.5 hover:bg-surface-container/70">
|
||||
<button
|
||||
type="button"
|
||||
title="Сменить тип блока"
|
||||
className="mt-1 flex h-5 min-w-16 shrink-0 items-center justify-center rounded bg-surface-container px-1.5 font-code text-[10px] text-on-surface-variant hover:text-primary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenMenu('convert')
|
||||
}}
|
||||
>
|
||||
{kind.chip}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
{block.type === 'bullet' ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<BlockText block={block} onText={onText} onEnter={onEnter} onDelete={onDelete} />
|
||||
</div>
|
||||
) : (
|
||||
<BlockText block={block} onText={onText} onEnter={onEnter} onDelete={onDelete} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menu ? (
|
||||
<div
|
||||
className="absolute top-8 left-0 z-20 w-64 rounded-lg border border-outline-variant bg-surface-container-high p-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="px-2 py-1 font-code text-label-sm tracking-wider text-outline uppercase">
|
||||
{menu === 'insert' ? 'Новый блок' : 'Превратить в'}
|
||||
</div>
|
||||
{BLOCK_KINDS.map((item) => (
|
||||
<button
|
||||
key={item.type}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-surface-container-highest"
|
||||
onClick={() => onChoose(item.type)}
|
||||
>
|
||||
<Icon name={item.icon} size={16} />
|
||||
<span className="min-w-0">
|
||||
<span className="block font-body text-body-md text-on-surface">{item.label}</span>
|
||||
<span className="block font-body text-body-sm text-on-surface-variant">{item.hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockText({
|
||||
block,
|
||||
onText,
|
||||
onEnter,
|
||||
onDelete,
|
||||
}: {
|
||||
block: Block
|
||||
onText: (text: string) => void
|
||||
onEnter: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const focused = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current
|
||||
if (!node || focused.current) return
|
||||
if (node.innerText !== block.text) node.innerText = block.text
|
||||
}, [block.text])
|
||||
|
||||
const multiline = block.type === 'code'
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 flex-1">
|
||||
{block.text === '' ? (
|
||||
<span className={`pointer-events-none absolute inset-x-0 top-0 text-outline ${blockClass(block.type)}`}>
|
||||
{placeholder(block.type)}
|
||||
</span>
|
||||
) : null}
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
className={`block-text relative ${blockClass(block.type)}`}
|
||||
onFocus={() => {
|
||||
focused.current = true
|
||||
}}
|
||||
onBlur={() => {
|
||||
focused.current = false
|
||||
}}
|
||||
onInput={() => onText(readText(ref.current))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !multiline && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
onEnter()
|
||||
}
|
||||
if (event.key === 'Backspace' && readText(ref.current) === '') {
|
||||
event.preventDefault()
|
||||
onDelete()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function readText(node: HTMLDivElement | null): string {
|
||||
if (!node) return ''
|
||||
return node.innerText.replace(/\n$/, '')
|
||||
}
|
||||
|
||||
function placeholder(type: BlockType): string {
|
||||
switch (type) {
|
||||
case 'h1':
|
||||
return 'Заголовок'
|
||||
case 'h2':
|
||||
return 'Подзаголовок'
|
||||
case 'h3':
|
||||
return 'Пункт'
|
||||
case 'bullet':
|
||||
return 'Пункт списка'
|
||||
case 'quote':
|
||||
return 'Цитата'
|
||||
case 'code':
|
||||
return 'Код'
|
||||
default:
|
||||
return 'Пустой блок'
|
||||
}
|
||||
}
|
||||
|
||||
function blockClass(type: BlockType): string {
|
||||
switch (type) {
|
||||
case 'h1':
|
||||
return 'block-h1'
|
||||
case 'h2':
|
||||
return 'block-h2'
|
||||
case 'h3':
|
||||
return 'block-h3'
|
||||
case 'quote':
|
||||
return 'block-quote'
|
||||
case 'code':
|
||||
return 'block-code'
|
||||
default:
|
||||
return 'block-paragraph'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { defaultKeymap, history, historyKeymap, redo, selectAll, undo } from '@codemirror/commands'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { Compartment, EditorState, type Extension } from '@codemirror/state'
|
||||
import {
|
||||
drawSelection,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const ghostHighlight = HighlightStyle.define([
|
||||
{ tag: tags.heading, color: '#8ed5ff', fontWeight: '600' },
|
||||
{ tag: [tags.heading1, tags.heading2, tags.heading3], color: '#8ed5ff' },
|
||||
{ tag: tags.strong, fontWeight: '700' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.monospace, color: '#7bd0ff' },
|
||||
{ tag: tags.url, color: '#7bd0ff' },
|
||||
{ tag: tags.link, color: '#c0c1ff' },
|
||||
{ tag: tags.quote, color: '#bdc8d1' },
|
||||
{ tag: tags.processingInstruction, color: '#c0c1ff' },
|
||||
{ tag: tags.meta, color: '#87929a' },
|
||||
])
|
||||
|
||||
const ghostTheme = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
height: '100%',
|
||||
backgroundColor: '#060e20',
|
||||
color: '#dae2fd',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px 0 48px',
|
||||
caretColor: '#38bdf8',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: '#060e20',
|
||||
color: '#87929a',
|
||||
borderRight: '1px solid #3e484f',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(56, 189, 248, 0.06)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'transparent',
|
||||
color: '#8ed5ff',
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#38bdf8' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(56, 189, 248, 0.22)',
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
)
|
||||
|
||||
const behaviorCompartment = new Compartment()
|
||||
const appearanceCompartment = new Compartment()
|
||||
|
||||
function behaviorExtension(showLineNumbers: boolean, lineWrapping: boolean, highlightLine: boolean): Extension {
|
||||
return [
|
||||
showLineNumbers ? [lineNumbers(), highlightActiveLineGutter()] : [],
|
||||
lineWrapping ? EditorView.lineWrapping : [],
|
||||
highlightLine ? highlightActiveLine() : [],
|
||||
]
|
||||
}
|
||||
|
||||
function appearanceExtension(fontSize: number, gutterSize: number): Extension {
|
||||
const lineHeight = `${Math.round(fontSize * 1.7)}px`
|
||||
return EditorView.theme({
|
||||
'.cm-scroller': {
|
||||
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight,
|
||||
},
|
||||
'.cm-gutters': {
|
||||
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
|
||||
fontSize: `${gutterSize}px`,
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 6px 0 8px',
|
||||
lineHeight,
|
||||
minWidth: '1.25rem',
|
||||
fontSize: `${gutterSize}px`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function baseExtensions(
|
||||
onChange: (text: string) => void,
|
||||
onCursor: (line: number, col: number) => void,
|
||||
options: {
|
||||
fontSize: number
|
||||
gutterSize: number
|
||||
showLineNumbers: boolean
|
||||
lineWrapping: boolean
|
||||
highlightLine: boolean
|
||||
},
|
||||
): Extension {
|
||||
return [
|
||||
behaviorCompartment.of(behaviorExtension(options.showLineNumbers, options.lineWrapping, options.highlightLine)),
|
||||
drawSelection(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
markdown(),
|
||||
syntaxHighlighting(ghostHighlight),
|
||||
ghostTheme,
|
||||
appearanceCompartment.of(appearanceExtension(options.fontSize, options.gutterSize)),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onChange(update.state.doc.toString())
|
||||
if (update.docChanged || update.selectionSet) {
|
||||
const pos = update.state.selection.main.head
|
||||
const line = update.state.doc.lineAt(pos)
|
||||
onCursor(line.number, pos - line.from + 1)
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
type SourceEditorProps = {
|
||||
active: boolean
|
||||
text: string
|
||||
focusLine: number
|
||||
focusToken: number
|
||||
fontSize: number
|
||||
gutterSize: number
|
||||
showLineNumbers: boolean
|
||||
lineWrapping: boolean
|
||||
highlightLine: boolean
|
||||
editorCommand: 'undo' | 'redo' | 'selectAll'
|
||||
editorCommandToken: number
|
||||
onScrollRatio?: (ratio: number) => void
|
||||
bindScrollTo?: (scrollTo: (ratio: number) => void) => void
|
||||
onChange: (text: string) => void
|
||||
onCursor: (line: number, col: number) => void
|
||||
}
|
||||
|
||||
export function SourceEditor({
|
||||
active,
|
||||
text,
|
||||
focusLine,
|
||||
focusToken,
|
||||
fontSize,
|
||||
gutterSize,
|
||||
showLineNumbers,
|
||||
lineWrapping,
|
||||
highlightLine,
|
||||
editorCommand,
|
||||
editorCommandToken,
|
||||
onScrollRatio,
|
||||
bindScrollTo,
|
||||
onChange,
|
||||
onCursor,
|
||||
}: SourceEditorProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onCursorRef = useRef(onCursor)
|
||||
const onScrollRatioRef = useRef(onScrollRatio)
|
||||
const activeRef = useRef(active)
|
||||
const optionsRef = useRef({ fontSize, gutterSize, showLineNumbers, lineWrapping, highlightLine })
|
||||
onChangeRef.current = onChange
|
||||
onCursorRef.current = onCursor
|
||||
onScrollRatioRef.current = onScrollRatio
|
||||
activeRef.current = active
|
||||
optionsRef.current = { fontSize, gutterSize, showLineNumbers, lineWrapping, highlightLine }
|
||||
|
||||
useEffect(() => {
|
||||
const parent = hostRef.current
|
||||
if (!parent || viewRef.current) return
|
||||
|
||||
const view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: text,
|
||||
extensions: baseExtensions(
|
||||
(next) => onChangeRef.current(next),
|
||||
(line, col) => onCursorRef.current(line, col),
|
||||
optionsRef.current,
|
||||
),
|
||||
}),
|
||||
})
|
||||
viewRef.current = view
|
||||
const head = view.state.selection.main.head
|
||||
const line = view.state.doc.lineAt(head)
|
||||
onCursorRef.current(line.number, head - line.from + 1)
|
||||
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || !active) return
|
||||
const current = view.state.doc.toString()
|
||||
if (current !== text) {
|
||||
const anchor = Math.min(view.state.selection.main.anchor, text.length)
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: text },
|
||||
selection: { anchor },
|
||||
})
|
||||
}
|
||||
}, [active, text])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || !active) return
|
||||
view.requestMeasure()
|
||||
view.focus()
|
||||
}, [active])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
if (behaviorCompartment.get(view.state) === undefined || appearanceCompartment.get(view.state) === undefined) return
|
||||
view.dispatch({
|
||||
effects: [
|
||||
behaviorCompartment.reconfigure(behaviorExtension(showLineNumbers, lineWrapping, highlightLine)),
|
||||
appearanceCompartment.reconfigure(appearanceExtension(fontSize, gutterSize)),
|
||||
],
|
||||
})
|
||||
view.requestMeasure()
|
||||
}, [fontSize, gutterSize, showLineNumbers, lineWrapping, highlightLine])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || editorCommandToken === 0) return
|
||||
if (editorCommand === 'undo') undo(view)
|
||||
else if (editorCommand === 'redo') redo(view)
|
||||
else selectAll(view)
|
||||
view.focus()
|
||||
}, [editorCommand, editorCommandToken])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const scroller = view.scrollDOM
|
||||
const emit = () => {
|
||||
const max = scroller.scrollHeight - scroller.clientHeight
|
||||
onScrollRatioRef.current?.(max <= 0 ? 0 : scroller.scrollTop / max)
|
||||
}
|
||||
scroller.addEventListener('scroll', emit, { passive: true })
|
||||
return () => scroller.removeEventListener('scroll', emit)
|
||||
}, [active])
|
||||
|
||||
useEffect(() => {
|
||||
bindScrollTo?.((ratio) => {
|
||||
const scroller = viewRef.current?.scrollDOM
|
||||
if (!scroller) return
|
||||
const max = scroller.scrollHeight - scroller.clientHeight
|
||||
scroller.scrollTop = ratio * Math.max(0, max)
|
||||
})
|
||||
}, [bindScrollTo])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || !activeRef.current || focusToken === 0) return
|
||||
const lineNo = Math.min(Math.max(1, focusLine), view.state.doc.lines)
|
||||
const line = view.state.doc.line(lineNo)
|
||||
view.dispatch({
|
||||
selection: { anchor: line.from },
|
||||
scrollIntoView: true,
|
||||
})
|
||||
view.focus()
|
||||
}, [focusLine, focusToken])
|
||||
|
||||
return <div ref={hostRef} className={active ? 'h-full min-h-0' : 'hidden'} />
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
export type BlockType = 'paragraph' | 'h1' | 'h2' | 'h3' | 'bullet' | 'quote' | 'code'
|
||||
|
||||
export type Block = {
|
||||
id: string
|
||||
type: BlockType
|
||||
text: string
|
||||
}
|
||||
|
||||
export const BLOCK_KINDS: Array<{
|
||||
type: BlockType
|
||||
label: string
|
||||
chip: string
|
||||
hint: string
|
||||
icon: string
|
||||
}> = [
|
||||
{ type: 'paragraph', label: 'Абзац', chip: 'Абзац', hint: 'Обычный текст', icon: 'notes' },
|
||||
{ type: 'h1', label: 'Заголовок 1', chip: 'H1', hint: 'Крупный раздел', icon: 'title' },
|
||||
{ type: 'h2', label: 'Заголовок 2', chip: 'H2', hint: 'Подраздел', icon: 'format_h2' },
|
||||
{ type: 'h3', label: 'Заголовок 3', chip: 'H3', hint: 'Пункт', icon: 'format_h3' },
|
||||
{ type: 'bullet', label: 'Список', chip: 'Список', hint: 'Один пункт', icon: 'format_list_bulleted' },
|
||||
{ type: 'quote', label: 'Цитата', chip: 'Цитата', hint: 'Выделенная мысль', icon: 'format_quote' },
|
||||
{ type: 'code', label: 'Код', chip: 'Код', hint: 'Моноширинный блок', icon: 'code' },
|
||||
]
|
||||
|
||||
let sequence = 0
|
||||
|
||||
export function createBlock(type: BlockType, text = ''): Block {
|
||||
sequence += 1
|
||||
return { id: `block-${sequence}`, type, text }
|
||||
}
|
||||
|
||||
function isFence(line: string): boolean {
|
||||
return line.startsWith('```')
|
||||
}
|
||||
|
||||
function isAtomic(line: string): boolean {
|
||||
return (
|
||||
isFence(line) ||
|
||||
line.startsWith('# ') ||
|
||||
line.startsWith('## ') ||
|
||||
line.startsWith('### ') ||
|
||||
line.startsWith('- ') ||
|
||||
line.startsWith('> ')
|
||||
)
|
||||
}
|
||||
|
||||
export function parseBlocks(markdown: string): Block[] {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n')
|
||||
const blocks: Block[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]
|
||||
if (line.trim() === '') {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (isFence(line)) {
|
||||
const body: string[] = []
|
||||
index += 1
|
||||
while (index < lines.length && !isFence(lines[index])) {
|
||||
body.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.length) index += 1
|
||||
blocks.push(createBlock('code', body.join('\n')))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('### ')) blocks.push(createBlock('h3', line.slice(4)))
|
||||
else if (line.startsWith('## ')) blocks.push(createBlock('h2', line.slice(3)))
|
||||
else if (line.startsWith('# ')) blocks.push(createBlock('h1', line.slice(2)))
|
||||
else if (line.startsWith('- ')) blocks.push(createBlock('bullet', line.slice(2)))
|
||||
else if (line.startsWith('> ')) blocks.push(createBlock('quote', line.slice(2)))
|
||||
else {
|
||||
const paragraph = [line]
|
||||
index += 1
|
||||
while (index < lines.length && lines[index].trim() !== '' && !isAtomic(lines[index])) {
|
||||
paragraph.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
blocks.push(createBlock('paragraph', paragraph.join('\n')))
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (blocks.length === 0) blocks.push(createBlock('paragraph'))
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function serializeBlocks(blocks: Block[]): string {
|
||||
return blocks
|
||||
.map((block) => {
|
||||
switch (block.type) {
|
||||
case 'h1':
|
||||
return `# ${block.text}`
|
||||
case 'h2':
|
||||
return `## ${block.text}`
|
||||
case 'h3':
|
||||
return `### ${block.text}`
|
||||
case 'bullet':
|
||||
return `- ${block.text}`
|
||||
case 'quote':
|
||||
return block.text
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n')
|
||||
case 'code':
|
||||
return `\`\`\`\n${block.text}\n\`\`\``
|
||||
default:
|
||||
return block.text
|
||||
}
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export function moveBlock(blocks: Block[], fromId: string, toId: string): Block[] {
|
||||
if (fromId === toId) return blocks
|
||||
const from = blocks.findIndex((block) => block.id === fromId)
|
||||
const to = blocks.findIndex((block) => block.id === toId)
|
||||
if (from < 0 || to < 0) return blocks
|
||||
const next = blocks.slice()
|
||||
const [item] = next.splice(from, 1)
|
||||
next.splice(to, 0, item)
|
||||
return next
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import type { ChromeApi } from '@shared/chrome'
|
||||
import type { HostApi } from '@shared/host'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
host: HostApi
|
||||
frame: ChromeApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,59 @@
|
||||
export type TreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
kind: 'file' | 'folder'
|
||||
icon: string
|
||||
tone: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
export type VisibleRow = {
|
||||
node: TreeNode
|
||||
depth: number
|
||||
}
|
||||
|
||||
export type Heading = {
|
||||
level: number
|
||||
title: string
|
||||
line: number
|
||||
}
|
||||
|
||||
export const WORKSPACE_NAME = 'reports'
|
||||
|
||||
export const DEFAULT_EXPANDED = ['dir:mirea', 'dir:mirea/schemas', 'dir:mirea/figures']
|
||||
|
||||
export function parentMap(
|
||||
nodes: TreeNode[],
|
||||
parent: string | null = null,
|
||||
map: Map<string, string | null> = new Map(),
|
||||
): Map<string, string | null> {
|
||||
for (const node of nodes) {
|
||||
map.set(node.id, parent)
|
||||
if (node.children) parentMap(node.children, node.id, map)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function visibleRows(nodes: TreeNode[], expanded: ReadonlySet<string>, depth = 0): VisibleRow[] {
|
||||
const rows: VisibleRow[] = []
|
||||
for (const node of nodes) {
|
||||
rows.push({ node, depth })
|
||||
if (node.kind === 'folder' && node.children && expanded.has(node.id)) {
|
||||
rows.push(...visibleRows(node.children, expanded, depth + 1))
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function documentHeadings(text: string): Heading[] {
|
||||
const headings: Heading[] = []
|
||||
const lines = text.split('\n')
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(lines[index] ?? '')
|
||||
if (!match) continue
|
||||
const title = (match[2] ?? '').replace(/[ \t]+#+$/, '').replace(/^\*\s?/, '').trim()
|
||||
if (!title) continue
|
||||
headings.push({ level: match[1]?.length ?? 1, title, line: index + 1 })
|
||||
}
|
||||
return headings
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type HelpTopic = {
|
||||
id: string
|
||||
title: string
|
||||
paragraphs: string[]
|
||||
}
|
||||
|
||||
export const HELP_TOPICS: HelpTopic[] = [
|
||||
{
|
||||
id: 'syntax',
|
||||
title: 'Синтаксис отчёта',
|
||||
paragraphs: [
|
||||
'Текст отчёта — Markdown. Заголовки начинаются с #. Звёздочка сразу после решёток снимает номер раздела: # *ВВЕДЕНИЕ, # *ЗАКЛЮЧЕНИЕ, # *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ.',
|
||||
'После # *СОДЕРЖАНИЕ ставится отдельная строка [TOC]. Точку в конце заголовка не ставят.',
|
||||
'Списки, жирный и курсив работают как в обычном Markdown. Источник в тексте — [1] или [2, 3]. Во введении и заключении таких ссылок быть не должно.',
|
||||
'Строка --- в этом редакторе видна как разрыв. Сборка DOCX её обработает, когда будет подключено ядро.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'objects',
|
||||
title: 'Таблицы, рисунки, формулы',
|
||||
paragraphs: [
|
||||
'Таблица: строка %id Название, затем таблица Markdown. В тексте ссылка пишется как @Таблица:id.',
|
||||
'Рисунок-файл: . В тексте — @Рисунок:id. Слово «Рисунок» не сокращают.',
|
||||
'Формула нумеруется, только если на неё есть ссылка. Блок: %id, затем $$ … $$, в тексте (@Формула:id).',
|
||||
'Листинг: %id Название и следом ограда с языком. Ссылка — @Листинг:id.',
|
||||
'Кнопки над редактором вставляют эти заготовки в текущую строку.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'diagrams',
|
||||
title: 'Схемы',
|
||||
paragraphs: [
|
||||
'Исходник схемы хранится в .puml или в ограде ```plantuml прямо в отчёте. Перед оградой — %id Название, тогда схема станет рисунком.',
|
||||
'Языки ограды: plantuml, uml, mermaid, idef0, dfd. Картинку из схемы соберёт ядро. Пока в окне виден исходник, а файлы .svg открываются картинкой.',
|
||||
'Готовые рисунки лежат в figures. На них можно сослаться из отчёта обычной картинкой Markdown.',
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
import '@fontsource/inter/600.css'
|
||||
import '@fontsource/space-grotesk/500.css'
|
||||
import '@fontsource/space-grotesk/600.css'
|
||||
import '@fontsource/jetbrains-mono/400.css'
|
||||
import '@fontsource/jetbrains-mono/500.css'
|
||||
import '@fontsource/jetbrains-mono/600.css'
|
||||
import './styles.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('Не найден #root')
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
export type PreviewBlock =
|
||||
| { type: 'h'; level: number; text: string }
|
||||
| { type: 'p'; text: string }
|
||||
| { type: 'ul'; items: string[] }
|
||||
| { type: 'ol'; items: string[] }
|
||||
| { type: 'quote'; text: string }
|
||||
| { type: 'code'; lang: string; text: string }
|
||||
| { type: 'table'; rows: string[][] }
|
||||
| { type: 'formula'; text: string }
|
||||
| { type: 'caption'; text: string }
|
||||
| { type: 'figure'; alt: string; src: string }
|
||||
| { type: 'break' }
|
||||
|
||||
export function previewBlocks(source: string): PreviewBlock[] {
|
||||
const lines = source.replace(/\r\n/g, '\n').split('\n')
|
||||
const blocks: PreviewBlock[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
if (line.trim() === '') {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const fence = /^(```|~~~)(.*)$/.exec(line)
|
||||
if (fence) {
|
||||
const marker = fence[1] ?? '```'
|
||||
const lang = (fence[2] ?? '').trim().split(/\s+/)[0] ?? ''
|
||||
const body: string[] = []
|
||||
index += 1
|
||||
while (index < lines.length && !(lines[index] ?? '').startsWith(marker)) {
|
||||
body.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.length) index += 1
|
||||
blocks.push({ type: 'code', lang, text: body.join('\n') })
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = /^(#{1,6})[ \t]+(.+?)\s*$/.exec(line)
|
||||
if (heading) {
|
||||
blocks.push({
|
||||
type: 'h',
|
||||
level: heading[1]?.length ?? 1,
|
||||
text: (heading[2] ?? '').replace(/\s+#+$/, '').replace(/^\*\s?/, ''),
|
||||
})
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.trim() === '[TOC]') {
|
||||
blocks.push({ type: 'p', text: 'Содержание формируется при сборке документа.' })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.trim() === '$$') {
|
||||
const body: string[] = []
|
||||
index += 1
|
||||
while (index < lines.length && (lines[index] ?? '').trim() !== '$$') {
|
||||
body.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.length) index += 1
|
||||
blocks.push({ type: 'formula', text: body.join('\n') })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
|
||||
blocks.push({ type: 'break' })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^%(?!%)\S+/.test(line)) {
|
||||
blocks.push({ type: 'caption', text: line.replace(/^%\S+[ \t]*/, '').trim() || line.slice(1) })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const image = /^!\[([^\]]*)]\(\s*<?([^)\s>]+)>?/.exec(line)
|
||||
if (image) {
|
||||
blocks.push({ type: 'figure', alt: image[1]?.trim() || 'Рисунок', src: image[2] ?? '' })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.includes('|')) {
|
||||
const rows: string[][] = []
|
||||
while (index < lines.length && (lines[index] ?? '').includes('|')) {
|
||||
const raw = (lines[index] ?? '').trim()
|
||||
index += 1
|
||||
if (/^[\s|:-]+$/.test(raw)) continue
|
||||
rows.push(
|
||||
raw
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim()),
|
||||
)
|
||||
}
|
||||
if (rows.length > 0) blocks.push({ type: 'table', rows })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(line)) {
|
||||
const quote: string[] = []
|
||||
while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) {
|
||||
quote.push((lines[index] ?? '').replace(/^>\s?/, ''))
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'quote', text: quote.join(' ') })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(line)) {
|
||||
const items: string[] = []
|
||||
while (index < lines.length && /^[-*]\s+/.test(lines[index] ?? '')) {
|
||||
items.push((lines[index] ?? '').replace(/^[-*]\s+/, ''))
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'ul', items })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d+[.)]\s+/.test(line)) {
|
||||
const items: string[] = []
|
||||
while (index < lines.length && /^\d+[.)]\s+/.test(lines[index] ?? '')) {
|
||||
items.push((lines[index] ?? '').replace(/^\d+[.)]\s+/, ''))
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'ol', items })
|
||||
continue
|
||||
}
|
||||
|
||||
const paragraph: string[] = []
|
||||
while (
|
||||
index < lines.length &&
|
||||
(lines[index] ?? '').trim() !== '' &&
|
||||
!/^(?:#{1,6}[ \t]|>|[-*]\s|\d+[.)]\s|```|~~~|\$\$|%(?!%)|!\[|---|\*\*\*|___)/.test(lines[index] ?? '') &&
|
||||
!(lines[index] ?? '').includes('|')
|
||||
) {
|
||||
paragraph.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'p', text: paragraph.join(' ') })
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
export type PreviewKind = 'markdown' | 'svg' | 'yaml' | 'bib' | 'diagram' | 'text'
|
||||
|
||||
export type YamlRow = { depth: number; key: string; value: string; note: boolean }
|
||||
|
||||
export type BibField = { key: string; value: string }
|
||||
export type BibEntry = { type: string; id: string; fields: BibField[] }
|
||||
|
||||
export type DiagramKind = 'person' | 'system' | 'external' | 'entity' | 'actor' | 'participant' | 'database' | 'usecase'
|
||||
|
||||
export type DiagramNode = {
|
||||
id: string
|
||||
label: string
|
||||
detail: string
|
||||
kind: DiagramKind
|
||||
fields: string[]
|
||||
}
|
||||
|
||||
export type DiagramEdge = { from: string; to: string; label: string }
|
||||
|
||||
export type DiagramModel = { title: string; nodes: DiagramNode[]; edges: DiagramEdge[] }
|
||||
|
||||
const DIAGRAM_EXT = new Set(['puml', 'plantuml', 'uml', 'wsd', 'iuml'])
|
||||
|
||||
export function previewKind(name: string, text: string): PreviewKind {
|
||||
const ext = name.includes('.') ? (name.split('.').pop()?.toLowerCase() ?? '') : ''
|
||||
const body = text.trimStart()
|
||||
if (ext === 'md' || ext === 'markdown') return 'markdown'
|
||||
if (ext === 'svg' || body.startsWith('<svg')) return 'svg'
|
||||
if (ext === 'yaml' || ext === 'yml') return 'yaml'
|
||||
if (ext === 'bib') return 'bib'
|
||||
if (DIAGRAM_EXT.has(ext) || body.includes('@startuml') || body.includes('@startmindmap')) return 'diagram'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function isDiagramLang(lang: string): boolean {
|
||||
return DIAGRAM_EXT.has(lang.toLowerCase()) || lang.toLowerCase() === 'mermaid'
|
||||
}
|
||||
|
||||
export function resolveWorkspacePath(fileId: string, href: string): string {
|
||||
const clean = href.trim().replace(/\\/g, '/')
|
||||
if (!clean || /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(clean)) return clean
|
||||
const stack = fileId.split('/').slice(0, -1).filter(Boolean)
|
||||
for (const part of clean.split('/')) {
|
||||
if (part === '' || part === '.') continue
|
||||
if (part === '..') stack.pop()
|
||||
else stack.push(part)
|
||||
}
|
||||
return stack.join('/')
|
||||
}
|
||||
|
||||
export function parseYamlPreview(source: string): YamlRow[] {
|
||||
const rows: YamlRow[] = []
|
||||
for (const line of source.replace(/\r\n/g, '\n').split('\n')) {
|
||||
if (line.trim() === '') continue
|
||||
const indent = /^\s*/.exec(line)?.[0].length ?? 0
|
||||
if (/^\s*#/.test(line)) {
|
||||
rows.push({ depth: indent, key: '', value: line.trim().replace(/^#\s?/, ''), note: true })
|
||||
continue
|
||||
}
|
||||
const item = /^(\s*)-\s+(.*)$/.exec(line)
|
||||
if (item) {
|
||||
rows.push({ depth: item[1]?.length ?? 0, key: '—', value: item[2]?.trim() ?? '', note: false })
|
||||
continue
|
||||
}
|
||||
const field = /^(\s*)([^:#]+):(.*)$/.exec(line)
|
||||
if (field) {
|
||||
rows.push({
|
||||
depth: field[1]?.length ?? 0,
|
||||
key: field[2]?.trim() ?? '',
|
||||
value: (field[3] ?? '').trim(),
|
||||
note: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
rows.push({ depth: indent, key: '', value: line.trim(), note: false })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function parseBib(source: string): BibEntry[] {
|
||||
const entries: BibEntry[] = []
|
||||
const start = /@(\w+)\s*\{\s*([^,\s]+)\s*,/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = start.exec(source))) {
|
||||
const from = match.index + match[0].length
|
||||
let depth = 1
|
||||
let index = from
|
||||
while (index < source.length && depth > 0) {
|
||||
const char = source[index]
|
||||
if (char === '{') depth += 1
|
||||
else if (char === '}') depth -= 1
|
||||
index += 1
|
||||
}
|
||||
const body = source.slice(from, Math.max(from, index - 1))
|
||||
const fields: BibField[] = []
|
||||
const field = /(\w+)\s*=\s*(?:\{([^{}]*)\}|"([^"]*)"|(\d+))/g
|
||||
let found: RegExpExecArray | null
|
||||
while ((found = field.exec(body))) {
|
||||
fields.push({ key: found[1] ?? '', value: found[2] ?? found[3] ?? found[4] ?? '' })
|
||||
}
|
||||
entries.push({ type: match[1] ?? '', id: match[2] ?? '', fields })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function splitArgs(body: string): string[] {
|
||||
const args: string[] = []
|
||||
let current = ''
|
||||
let quote = ''
|
||||
for (const char of body) {
|
||||
if (quote) {
|
||||
if (char === quote) quote = ''
|
||||
else current += char
|
||||
continue
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
if (char === ',') {
|
||||
args.push(current.trim())
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
current += char
|
||||
}
|
||||
if (current.trim()) args.push(current.trim())
|
||||
return args
|
||||
}
|
||||
|
||||
export function parseDiagram(source: string): DiagramModel {
|
||||
const lines = source.replace(/\r\n/g, '\n').split('\n')
|
||||
const nodes: DiagramNode[] = []
|
||||
const byKey = new Map<string, DiagramNode>()
|
||||
const edges: DiagramEdge[] = []
|
||||
let title = ''
|
||||
|
||||
function add(id: string, label: string, kind: DiagramKind, detail = '', fields: string[] = []): DiagramNode {
|
||||
const key = id || label
|
||||
const existing = byKey.get(key)
|
||||
if (existing) {
|
||||
if (label) existing.label = label
|
||||
existing.kind = kind
|
||||
if (detail) existing.detail = detail
|
||||
if (fields.length) existing.fields = fields
|
||||
if (label && label !== existing.id) byKey.set(label, existing)
|
||||
return existing
|
||||
}
|
||||
const node: DiagramNode = { id: key, label: label || key, detail, kind, fields }
|
||||
nodes.push(node)
|
||||
byKey.set(node.id, node)
|
||||
if (node.label !== node.id) byKey.set(node.label, node)
|
||||
return node
|
||||
}
|
||||
|
||||
function canon(token: string): string {
|
||||
const name = token.replace(/^["']|["']$/g, '').trim()
|
||||
return byKey.get(name)?.id ?? name
|
||||
}
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = (lines[index] ?? '').trim()
|
||||
if (!line || line.startsWith("'") || line.startsWith('@') || line.startsWith('!') || /^skinparam\b/i.test(line)) continue
|
||||
const titleLine = /^title\s+(.+)$/i.exec(line)
|
||||
if (titleLine) {
|
||||
title = titleLine[1]?.trim() ?? ''
|
||||
continue
|
||||
}
|
||||
const call = /^(Person|System|System_Ext|SystemDb)\s*\((.*)\)\s*$/.exec(line)
|
||||
if (call) {
|
||||
const args = splitArgs(call[2] ?? '')
|
||||
const kind: DiagramKind = call[1] === 'Person' ? 'person' : call[1] === 'System_Ext' ? 'external' : 'system'
|
||||
add(args[0] ?? '', args[1] ?? args[0] ?? '', kind, args[2] ?? '')
|
||||
continue
|
||||
}
|
||||
const entity = /^entity\s+(?:"([^"]+)"|(\S+))(?:\s+as\s+(\S+))?\s*(\{)?\s*$/.exec(line)
|
||||
if (entity) {
|
||||
const label = entity[1] || entity[2] || ''
|
||||
const id = entity[3] || label
|
||||
const fields: string[] = []
|
||||
if (entity[4]) {
|
||||
index += 1
|
||||
while (index < lines.length && !(lines[index] ?? '').trim().startsWith('}')) {
|
||||
const field = (lines[index] ?? '').trim().replace(/^\*\s*/, '')
|
||||
if (field && field !== '--') fields.push(field)
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
add(id, label, 'entity', '', fields)
|
||||
continue
|
||||
}
|
||||
const decl = /^(actor|participant|database)\s+(?:"([^"]+)"|(\S+))(?:\s+as\s+(\S+))?\s*$/.exec(line)
|
||||
if (decl) {
|
||||
const label = decl[2] || decl[3] || ''
|
||||
const id = decl[4] || label
|
||||
const kind: DiagramKind = decl[1] === 'actor' ? 'actor' : decl[1] === 'database' ? 'database' : 'participant'
|
||||
add(id, label, kind)
|
||||
continue
|
||||
}
|
||||
const rel = /^Rel(?:_\w+)?\s*\((.*)\)\s*$/.exec(line)
|
||||
if (rel) {
|
||||
const args = splitArgs(rel[1] ?? '')
|
||||
const from = canon(args[0] ?? '')
|
||||
const to = canon(args[1] ?? '')
|
||||
if (from && !byKey.has(from)) add(from, from, 'system')
|
||||
if (to && !byKey.has(to)) add(to, to, 'system')
|
||||
if (from && to) edges.push({ from, to, label: args[2] ?? '' })
|
||||
continue
|
||||
}
|
||||
const usecase = /^(.+?)\s+-+>\s+\(([^)]+)\)\s*(?::\s*(.*))?$/.exec(line)
|
||||
if (usecase) {
|
||||
const from = canon(usecase[1] ?? '')
|
||||
const to = canon(usecase[2] ?? '')
|
||||
if (!byKey.has(from)) add(from, from, 'actor')
|
||||
if (!byKey.has(to)) add(to, to, 'usecase')
|
||||
edges.push({ from, to, label: (usecase[3] ?? '').trim() })
|
||||
continue
|
||||
}
|
||||
const arrow = /^(.+?)\s+(<?[|ox.*{}-]*-+[|ox.*{}-]*>?)\s+(.+?)(?:\s*:\s*(.*))?$/.exec(line)
|
||||
if (arrow) {
|
||||
const structural = (arrow[2] ?? '').includes('|') || (arrow[2] ?? '').includes('{')
|
||||
const from = canon((arrow[1] ?? '').trim())
|
||||
const to = canon((arrow[3] ?? '').trim())
|
||||
const kind: DiagramKind = structural ? 'entity' : 'participant'
|
||||
if (from && !byKey.has(from)) add(from, from, kind)
|
||||
if (to && !byKey.has(to)) add(to, to, kind)
|
||||
if (from && to) edges.push({ from, to, label: (arrow[4] ?? '').trim() })
|
||||
}
|
||||
}
|
||||
|
||||
return { title, nodes, edges }
|
||||
}
|
||||
|
||||
export function diagramLayout(model: DiagramModel): 'sequence' | 'entities' | 'blocks' {
|
||||
if (model.nodes.some((node) => node.kind === 'entity')) return 'entities'
|
||||
const lifelines = model.nodes.filter((node) => node.kind === 'actor' || node.kind === 'participant' || node.kind === 'database')
|
||||
if (lifelines.length >= 2 && model.edges.length > 0) return 'sequence'
|
||||
return 'blocks'
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
ACTIVE_FILE_ID,
|
||||
insertAtCursor,
|
||||
makeSnippet,
|
||||
seedFiles,
|
||||
type InsertKind,
|
||||
type WorkspaceFile,
|
||||
} from './documents'
|
||||
|
||||
export type EditorMode = 'code' | 'notion'
|
||||
|
||||
export type CursorPos = { line: number; col: number }
|
||||
|
||||
export type EditorCommand = 'undo' | 'redo' | 'selectAll'
|
||||
|
||||
type SessionValue = {
|
||||
text: string
|
||||
setText: (text: string) => void
|
||||
files: Record<string, WorkspaceFile>
|
||||
folders: string[][]
|
||||
activeId: string
|
||||
openFile: (id: string, line?: number) => void
|
||||
createFile: () => string
|
||||
createFolder: () => string
|
||||
insertSnippet: (kind: InsertKind) => void
|
||||
activity: string[]
|
||||
mode: EditorMode
|
||||
setMode: (mode: EditorMode) => void
|
||||
cursor: CursorPos
|
||||
setCursor: (cursor: CursorPos) => void
|
||||
notice: string | null
|
||||
setNotice: (notice: string | null) => void
|
||||
focusLine: number
|
||||
focusToken: number
|
||||
focusLineAt: (line: number) => void
|
||||
editorCommand: EditorCommand
|
||||
editorCommandToken: number
|
||||
runEditorCommand: (command: EditorCommand) => void
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionValue | null>(null)
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const seeded = useRef(seedFiles())
|
||||
const [files, setFiles] = useState<Record<string, WorkspaceFile>>(seeded.current)
|
||||
const [folders, setFolders] = useState<string[][]>([])
|
||||
const [activeId, setActiveId] = useState(ACTIVE_FILE_ID)
|
||||
const [text, setTextState] = useState(seeded.current[ACTIVE_FILE_ID]?.text ?? '')
|
||||
const [mode, setMode] = useState<EditorMode>('code')
|
||||
const [cursor, setCursor] = useState<CursorPos>({ line: 1, col: 1 })
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [focusLine, setFocusLine] = useState(1)
|
||||
const [focusToken, setFocusToken] = useState(0)
|
||||
const [activity, setActivity] = useState<string[]>([])
|
||||
const [editorCommand, setEditorCommand] = useState<EditorCommand>('undo')
|
||||
const [editorCommandToken, setEditorCommandToken] = useState(0)
|
||||
const tokenRef = useRef(0)
|
||||
const commandRef = useRef(0)
|
||||
const activeIdRef = useRef(ACTIVE_FILE_ID)
|
||||
const filesRef = useRef(seeded.current)
|
||||
const textRef = useRef(text)
|
||||
const cursorRef = useRef(cursor)
|
||||
const snippetRef = useRef(0)
|
||||
const fileCount = useRef(0)
|
||||
const folderCount = useRef(0)
|
||||
textRef.current = text
|
||||
cursorRef.current = cursor
|
||||
filesRef.current = files
|
||||
|
||||
const remember = useCallback((message: string) => {
|
||||
setActivity((prev) => [message, ...prev].slice(0, 12))
|
||||
}, [])
|
||||
|
||||
const focusLineAt = useCallback((line: number) => {
|
||||
tokenRef.current += 1
|
||||
setFocusLine(line)
|
||||
setFocusToken(tokenRef.current)
|
||||
}, [])
|
||||
|
||||
const runEditorCommand = useCallback((command: EditorCommand) => {
|
||||
commandRef.current += 1
|
||||
setEditorCommand(command)
|
||||
setEditorCommandToken(commandRef.current)
|
||||
}, [])
|
||||
|
||||
const setText = useCallback((next: string) => {
|
||||
setTextState(next)
|
||||
const id = activeIdRef.current
|
||||
setFiles((prev) => {
|
||||
const file = prev[id]
|
||||
if (!file || file.text === next) return prev
|
||||
return { ...prev, [id]: { ...file, text: next } }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const openFile = useCallback(
|
||||
(id: string, line = 1) => {
|
||||
const file = filesRef.current[id]
|
||||
if (!file) return
|
||||
if (activeIdRef.current !== id) remember(`Открыт ${file.name}`)
|
||||
activeIdRef.current = id
|
||||
setActiveId(id)
|
||||
setTextState(file.text)
|
||||
setMode('code')
|
||||
focusLineAt(line)
|
||||
},
|
||||
[focusLineAt, remember],
|
||||
)
|
||||
|
||||
const createFile = useCallback(() => {
|
||||
fileCount.current += 1
|
||||
const name = `заметка-${fileCount.current}.md`
|
||||
const id = `mirea/${name}`
|
||||
const blank = '# \n\n'
|
||||
const created: WorkspaceFile = {
|
||||
id,
|
||||
name,
|
||||
dir: ['mirea'],
|
||||
icon: 'description',
|
||||
tone: 'text-primary',
|
||||
text: blank,
|
||||
initial: blank,
|
||||
}
|
||||
setFiles((prev) => ({ ...prev, [id]: created }))
|
||||
activeIdRef.current = id
|
||||
setActiveId(id)
|
||||
setTextState(blank)
|
||||
setMode('code')
|
||||
remember(`Создан ${name}`)
|
||||
focusLineAt(1)
|
||||
return id
|
||||
}, [focusLineAt, remember])
|
||||
|
||||
const createFolder = useCallback(() => {
|
||||
folderCount.current += 1
|
||||
const name = `папка-${folderCount.current}`
|
||||
setFolders((prev) => [...prev, ['mirea', name]])
|
||||
remember(`Создана папка ${name}`)
|
||||
return `dir:mirea/${name}`
|
||||
}, [remember])
|
||||
|
||||
const insertSnippet = useCallback(
|
||||
(kind: InsertKind) => {
|
||||
snippetRef.current += 1
|
||||
const snippet = makeSnippet(kind, snippetRef.current)
|
||||
const placed = insertAtCursor(textRef.current, cursorRef.current.line, cursorRef.current.col, snippet)
|
||||
setText(placed.text)
|
||||
setMode('code')
|
||||
focusLineAt(placed.line)
|
||||
remember('Вставлен фрагмент')
|
||||
},
|
||||
[focusLineAt, remember, setText],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
text,
|
||||
setText,
|
||||
files,
|
||||
folders,
|
||||
activeId,
|
||||
openFile,
|
||||
createFile,
|
||||
createFolder,
|
||||
insertSnippet,
|
||||
activity,
|
||||
mode,
|
||||
setMode,
|
||||
cursor,
|
||||
setCursor,
|
||||
notice,
|
||||
setNotice,
|
||||
focusLine,
|
||||
focusToken,
|
||||
focusLineAt,
|
||||
editorCommand,
|
||||
editorCommandToken,
|
||||
runEditorCommand,
|
||||
}),
|
||||
[
|
||||
text,
|
||||
setText,
|
||||
files,
|
||||
folders,
|
||||
activeId,
|
||||
openFile,
|
||||
createFile,
|
||||
createFolder,
|
||||
insertSnippet,
|
||||
activity,
|
||||
mode,
|
||||
cursor,
|
||||
notice,
|
||||
focusLine,
|
||||
focusToken,
|
||||
focusLineAt,
|
||||
editorCommand,
|
||||
editorCommandToken,
|
||||
runEditorCommand,
|
||||
],
|
||||
)
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
}
|
||||
|
||||
export function useSession(): SessionValue {
|
||||
const value = useContext(SessionContext)
|
||||
if (!value) throw new Error('useSession вне SessionProvider')
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
export const DOC_PROFILES = [
|
||||
{ id: 'coursework', label: 'Курсовая (coursework)', badge: 'Курсовая работа (МИРЭА)' },
|
||||
{ id: 'practice', label: 'Практика (practice)', badge: 'Практика (МИРЭА)' },
|
||||
{ id: 'vkr', label: 'ВКР (vkr)', badge: 'ВКР (МИРЭА)' },
|
||||
{ id: 'PIS_custom', label: 'ПИС (PIS_custom)', badge: 'ПИС' },
|
||||
{ id: 'APID_coursework', label: 'АПИД (APID_coursework)', badge: 'АПИД' },
|
||||
] as const
|
||||
|
||||
export type DocProfileId = (typeof DOC_PROFILES)[number]['id']
|
||||
|
||||
export type PreviewSpacing = 100 | 115 | 150
|
||||
export type PreviewMargin = 'narrow' | 'normal' | 'wide'
|
||||
|
||||
export type Settings = {
|
||||
fontSize: number
|
||||
gutterSize: number
|
||||
showLineNumbers: boolean
|
||||
lineWrapping: boolean
|
||||
highlightLine: boolean
|
||||
zoom: number
|
||||
split: boolean
|
||||
syncScroll: boolean
|
||||
previewFontSize: number
|
||||
previewSpacing: PreviewSpacing
|
||||
previewMargin: PreviewMargin
|
||||
profile: DocProfileId
|
||||
student: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
fontSize: 13,
|
||||
gutterSize: 10,
|
||||
showLineNumbers: true,
|
||||
lineWrapping: true,
|
||||
highlightLine: true,
|
||||
zoom: 100,
|
||||
split: false,
|
||||
syncScroll: true,
|
||||
previewFontSize: 14,
|
||||
previewSpacing: 150,
|
||||
previewMargin: 'normal',
|
||||
profile: 'coursework',
|
||||
student: '',
|
||||
group: '',
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ghosteditor.settings'
|
||||
const PROFILE_IDS = new Set<string>(DOC_PROFILES.map((item) => item.id))
|
||||
|
||||
function oneOf<T extends string | number>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return allowed.includes(value as T) ? (value as T) : fallback
|
||||
}
|
||||
|
||||
function clamp(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const number = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(number)) return fallback
|
||||
return Math.min(max, Math.max(min, Math.round(number)))
|
||||
}
|
||||
|
||||
export function sanitizeSettings(raw: unknown): Settings {
|
||||
const source = raw && typeof raw === 'object' ? (raw as Partial<Settings>) : {}
|
||||
const profile =
|
||||
typeof source.profile === 'string' && PROFILE_IDS.has(source.profile)
|
||||
? (source.profile as DocProfileId)
|
||||
: DEFAULT_SETTINGS.profile
|
||||
return {
|
||||
fontSize: clamp(source.fontSize, 11, 20, DEFAULT_SETTINGS.fontSize),
|
||||
gutterSize: clamp(source.gutterSize, 8, 16, DEFAULT_SETTINGS.gutterSize),
|
||||
showLineNumbers:
|
||||
typeof source.showLineNumbers === 'boolean' ? source.showLineNumbers : DEFAULT_SETTINGS.showLineNumbers,
|
||||
lineWrapping: typeof source.lineWrapping === 'boolean' ? source.lineWrapping : DEFAULT_SETTINGS.lineWrapping,
|
||||
highlightLine: typeof source.highlightLine === 'boolean' ? source.highlightLine : DEFAULT_SETTINGS.highlightLine,
|
||||
zoom: clamp(source.zoom, 70, 180, DEFAULT_SETTINGS.zoom),
|
||||
split: typeof source.split === 'boolean' ? source.split : DEFAULT_SETTINGS.split,
|
||||
syncScroll: typeof source.syncScroll === 'boolean' ? source.syncScroll : DEFAULT_SETTINGS.syncScroll,
|
||||
previewFontSize: clamp(source.previewFontSize, 12, 20, DEFAULT_SETTINGS.previewFontSize),
|
||||
previewSpacing: oneOf(source.previewSpacing, [100, 115, 150] as const, DEFAULT_SETTINGS.previewSpacing),
|
||||
previewMargin: oneOf(source.previewMargin, ['narrow', 'normal', 'wide'] as const, DEFAULT_SETTINGS.previewMargin),
|
||||
profile,
|
||||
student: typeof source.student === 'string' ? source.student : '',
|
||||
group: typeof source.group === 'string' ? source.group : '',
|
||||
}
|
||||
}
|
||||
|
||||
function readSettings(): Settings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_SETTINGS
|
||||
return sanitizeSettings(JSON.parse(raw) as unknown)
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(settings: Settings): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch {
|
||||
/* останутся в памяти до закрытия окна */
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsValue = {
|
||||
settings: Settings
|
||||
updateSettings: (patch: Partial<Settings>) => void
|
||||
resetEditorSettings: () => void
|
||||
resetPreviewSettings: () => void
|
||||
open: boolean
|
||||
openSettings: () => void
|
||||
toggleOpen: () => void
|
||||
profile: (typeof DOC_PROFILES)[number]
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsValue | null>(null)
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState(readSettings)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const updateSettings = useCallback((patch: Partial<Settings>) => {
|
||||
setSettings((current) => {
|
||||
const next = sanitizeSettings({ ...current, ...patch })
|
||||
writeSettings(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetEditorSettings = useCallback(() => {
|
||||
setSettings((current) => {
|
||||
const next = sanitizeSettings({
|
||||
...current,
|
||||
fontSize: DEFAULT_SETTINGS.fontSize,
|
||||
gutterSize: DEFAULT_SETTINGS.gutterSize,
|
||||
showLineNumbers: DEFAULT_SETTINGS.showLineNumbers,
|
||||
lineWrapping: DEFAULT_SETTINGS.lineWrapping,
|
||||
highlightLine: DEFAULT_SETTINGS.highlightLine,
|
||||
zoom: DEFAULT_SETTINGS.zoom,
|
||||
})
|
||||
writeSettings(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetPreviewSettings = useCallback(() => {
|
||||
setSettings((current) => {
|
||||
const next = sanitizeSettings({
|
||||
...current,
|
||||
previewFontSize: DEFAULT_SETTINGS.previewFontSize,
|
||||
previewSpacing: DEFAULT_SETTINGS.previewSpacing,
|
||||
previewMargin: DEFAULT_SETTINGS.previewMargin,
|
||||
})
|
||||
writeSettings(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const openSettings = useCallback(() => setOpen(true), [])
|
||||
const toggleOpen = useCallback(() => setOpen((value) => !value), [])
|
||||
const profile = DOC_PROFILES.find((item) => item.id === settings.profile) ?? DOC_PROFILES[0]
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ settings, updateSettings, resetEditorSettings, resetPreviewSettings, open, openSettings, toggleOpen, profile }),
|
||||
[settings, updateSettings, resetEditorSettings, resetPreviewSettings, open, openSettings, toggleOpen, profile],
|
||||
)
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>
|
||||
}
|
||||
|
||||
export function useSettings(): SettingsValue {
|
||||
const value = useContext(SettingsContext)
|
||||
if (!value) throw new Error('useSettings вне SettingsProvider')
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 100 700;
|
||||
font-display: block;
|
||||
src: url('../../../node_modules/material-symbols/material-symbols-outlined.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-surface: #0b1326;
|
||||
--color-surface-dim: #0b1326;
|
||||
--color-surface-bright: #31394d;
|
||||
--color-surface-container-lowest: #060e20;
|
||||
--color-surface-container-low: #131b2e;
|
||||
--color-surface-container: #171f33;
|
||||
--color-surface-container-high: #222a3d;
|
||||
--color-surface-container-highest: #2d3449;
|
||||
--color-on-surface: #dae2fd;
|
||||
--color-on-surface-variant: #bdc8d1;
|
||||
--color-inverse-surface: #dae2fd;
|
||||
--color-inverse-on-surface: #283044;
|
||||
--color-outline: #87929a;
|
||||
--color-outline-variant: #3e484f;
|
||||
--color-surface-tint: #7bd0ff;
|
||||
--color-primary: #8ed5ff;
|
||||
--color-on-primary: #00354a;
|
||||
--color-primary-container: #38bdf8;
|
||||
--color-on-primary-container: #004965;
|
||||
--color-inverse-primary: #00668a;
|
||||
--color-secondary: #c0c1ff;
|
||||
--color-on-secondary: #1000a9;
|
||||
--color-secondary-container: #3131c0;
|
||||
--color-on-secondary-container: #b0b2ff;
|
||||
--color-tertiary: #56e5a9;
|
||||
--color-on-tertiary: #003824;
|
||||
--color-tertiary-container: #30c88f;
|
||||
--color-on-tertiary-container: #004e34;
|
||||
--color-error: #ffb4ab;
|
||||
--color-on-error: #690005;
|
||||
--color-error-container: #93000a;
|
||||
--color-on-error-container: #ffdad6;
|
||||
--color-primary-fixed: #c4e7ff;
|
||||
--color-primary-fixed-dim: #7bd0ff;
|
||||
--color-on-primary-fixed: #001e2c;
|
||||
--color-on-primary-fixed-variant: #004c69;
|
||||
--color-secondary-fixed: #e1e0ff;
|
||||
--color-secondary-fixed-dim: #c0c1ff;
|
||||
--color-on-secondary-fixed: #07006c;
|
||||
--color-on-secondary-fixed-variant: #2f2ebe;
|
||||
--color-tertiary-fixed: #6ffbbe;
|
||||
--color-tertiary-fixed-dim: #4edea3;
|
||||
--color-on-tertiary-fixed: #002113;
|
||||
--color-on-tertiary-fixed-variant: #005236;
|
||||
--color-background: #0b1326;
|
||||
--color-on-background: #dae2fd;
|
||||
--color-surface-variant: #2d3449;
|
||||
|
||||
--font-headline: 'Space Grotesk', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
--font-code: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
--text-headline-lg: 18px;
|
||||
--text-headline-lg--line-height: 24px;
|
||||
--text-headline-lg--letter-spacing: -0.01em;
|
||||
--text-headline-lg--font-weight: 600;
|
||||
--text-headline-md: 15px;
|
||||
--text-headline-md--line-height: 20px;
|
||||
--text-headline-md--letter-spacing: -0.005em;
|
||||
--text-headline-md--font-weight: 600;
|
||||
--text-headline-sm: 13px;
|
||||
--text-headline-sm--line-height: 18px;
|
||||
--text-headline-sm--font-weight: 500;
|
||||
--text-body-lg: 13px;
|
||||
--text-body-lg--line-height: 20px;
|
||||
--text-body-md: 12px;
|
||||
--text-body-md--line-height: 18px;
|
||||
--text-body-sm: 11px;
|
||||
--text-body-sm--line-height: 16px;
|
||||
--text-code-editor: 13px;
|
||||
--text-code-editor--line-height: 22px;
|
||||
--text-code-gutter: 11px;
|
||||
--text-code-gutter--line-height: 22px;
|
||||
--text-label-md: 11px;
|
||||
--text-label-md--line-height: 14px;
|
||||
--text-label-md--letter-spacing: 0.02em;
|
||||
--text-label-md--font-weight: 500;
|
||||
--text-label-sm: 10px;
|
||||
--text-label-sm--line-height: 12px;
|
||||
--text-label-sm--letter-spacing: 0.03em;
|
||||
--text-label-sm--font-weight: 500;
|
||||
--text-status-indicator: 10px;
|
||||
--text-status-indicator--line-height: 12px;
|
||||
--text-status-indicator--font-weight: 600;
|
||||
|
||||
--radius-sm: 0.125rem;
|
||||
--radius: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--spacing-gutter-xs: 0.25rem;
|
||||
--spacing-gutter-sm: 0.5rem;
|
||||
--spacing-gutter-md: 0.75rem;
|
||||
--spacing-gutter-lg: 1rem;
|
||||
--spacing-panel-padding-compact: 0.375rem;
|
||||
--spacing-panel-padding-default: 0.75rem;
|
||||
--spacing-window-bar-height: 2.25rem;
|
||||
--spacing-status-bar-height: 1.625rem;
|
||||
--spacing-sidebar-min-width: 15rem;
|
||||
--spacing-sidebar-default-width: 18rem;
|
||||
--spacing-sidebar-max-width: 26rem;
|
||||
--spacing-page-canvas-padding: 2rem;
|
||||
--spacing-toolbar-height: 2.75rem;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' 24;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3e484f;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.notion-editor {
|
||||
user-select: text;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(56, 189, 248, 0.04), transparent 120px),
|
||||
var(--color-surface-container-lowest);
|
||||
}
|
||||
|
||||
.block-text {
|
||||
min-height: 1.6em;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.block-text:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--color-outline);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.block-paragraph {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
color: var(--color-on-surface);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.block-h1,
|
||||
.block-h2,
|
||||
.block-h3 {
|
||||
font-family: var(--font-headline);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.block-h1 {
|
||||
font-size: 28px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.block-h2 {
|
||||
font-size: 22px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.block-h3 {
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.block-quote {
|
||||
border-left: 2px solid var(--color-primary-container);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block-code {
|
||||
font-family: var(--font-code);
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
background: var(--color-surface-container);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
user-select: text;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TreeNode } from './explorer'
|
||||
import type { WorkspaceFile } from './documents'
|
||||
|
||||
export function buildTree(files: WorkspaceFile[], folders: string[][]): TreeNode[] {
|
||||
type Dir = { folders: Map<string, Dir>; files: WorkspaceFile[] }
|
||||
const root: Dir = { folders: new Map(), files: [] }
|
||||
|
||||
function ensure(parts: string[]): Dir {
|
||||
let cursor = root
|
||||
for (const part of parts) {
|
||||
let next = cursor.folders.get(part)
|
||||
if (!next) {
|
||||
next = { folders: new Map(), files: [] }
|
||||
cursor.folders.set(part, next)
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
for (const folder of folders) ensure(folder)
|
||||
for (const file of files) ensure(file.dir).files.push(file)
|
||||
|
||||
function toNodes(dir: Dir, prefix: string[]): TreeNode[] {
|
||||
const folderNodes = [...dir.folders.entries()].map(([name, child]) => ({
|
||||
id: `dir:${[...prefix, name].join('/')}`,
|
||||
name,
|
||||
kind: 'folder' as const,
|
||||
icon: 'folder',
|
||||
tone: 'text-primary',
|
||||
children: toNodes(child, [...prefix, name]),
|
||||
}))
|
||||
const fileNodes = dir.files.map((file) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
kind: 'file' as const,
|
||||
icon: file.icon,
|
||||
tone: file.tone,
|
||||
}))
|
||||
return [...folderNodes, ...fileNodes]
|
||||
}
|
||||
|
||||
return toNodes(root, [])
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Кнопки рамки окна. К конвейеру md2gost не относятся. */
|
||||
export interface ChromeApi {
|
||||
/** darwin — кнопки слева, остальные системы — справа. */
|
||||
platform: string
|
||||
minimize(): Promise<void>
|
||||
toggleMaximize(): Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Результат вызова моста. Сборка ГОСТ по-прежнему без Python, поэтому check и build отвечают «не подключено». */
|
||||
export type HostResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
export const HOST_NOT_CONNECTED: HostResult = { ok: false, error: 'не подключено' }
|
||||
|
||||
export type EditorFile = { id: string; text: string }
|
||||
|
||||
/** Черновик Markdown остаётся в окне. Страницу открывает WordCraft. */
|
||||
export interface HostApi {
|
||||
open(): Promise<HostResult>
|
||||
save(text: string): Promise<HostResult>
|
||||
check(text: string): Promise<HostResult>
|
||||
build(text: string): Promise<HostResult>
|
||||
edit(files: EditorFile[], activeId: string): Promise<HostResult>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["node", "electron-vite/node"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": [
|
||||
"electron.vite.config.ts",
|
||||
"src/main/**/*.ts",
|
||||
"src/preload/**/*.ts",
|
||||
"src/shared/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["electron-vite/node"],
|
||||
"paths": {
|
||||
"@renderer/*": ["./src/renderer/src/*"],
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/renderer/src/**/*.ts", "src/renderer/src/**/*.tsx", "src/shared/**/*.ts"]
|
||||
}
|
||||
+1
-1
@@ -655,7 +655,7 @@ Rel_Triggering(investigate, close)
|
||||
- Полная Appendix B в sample отсутствует — матрица пар здесь практическая, не нормативная таблица B.5.
|
||||
- PDF спецификации в git не хранятся.
|
||||
- Старый PlantUML/Kroki без stdlib 3.2: fallback — `` ```uml `` + keyword `archimate`.
|
||||
- Пользовательский `md2gost.schemes.json`: встроенные схемы с `author: md2gost` синхронизируются с бандлом; свои правки (другой author) не затираются.
|
||||
- Пользовательский `md2gost.schemes.json` при запуске только дополняется отсутствующими встроенными схемами. Уже записанные правки не затираются.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -34,6 +34,7 @@ python -m word2md report.docx -o report.md
|
||||
| `--check-pages` | Проверить вёрстку в Word | эвристика полупустых страниц (Word + pywin32) |
|
||||
| `--table-continuation` | Основные | `word` (по умолчанию), `off`, `legacy`, `caption`, `soft` |
|
||||
| `--listing-continuation` | Основные | то же для листингов |
|
||||
| `--office-engine` | Основные → Движок пост-разрыва | `auto` (Word → LibreOffice → OpenOffice), `word`, `libre`, `openoffice` |
|
||||
| `--table-repeat-header` | Галочка | шапка на фрагментах после word-split |
|
||||
| `--plantuml-jar` / `--kroki-url` | Настройки → Диаграммы | локальный рендер UML / запасной Kroki |
|
||||
| `--diagram-fallback` | Диаграммы | `remote` / `local` / `off` (отключает только kroki.io) |
|
||||
@@ -49,7 +50,7 @@ python -m word2md report.docx -o report.md
|
||||
|
||||
## Продолжение таблиц и листингов
|
||||
|
||||
DOCX не умеет сам писать «Продолжение Таблицы N» только со 2-й страницы. Режим **`word`** (по умолчанию): после сохранения Word COM режет по реальной пагинации. Нужны Windows + Word + pywin32. Без Word: `--table-continuation off` (и то же для листингов).
|
||||
DOCX не умеет сам писать «Продолжение Таблицы N» только со 2-й страницы. Режим **`word`** (по умолчанию): после сохранения режет по реальной пагинации выбранным движком (`--office-engine`): Microsoft Word COM (Windows + pywin32) или LibreOffice/OpenOffice Writer через UNO (`soffice` + `program/python`). По умолчанию `auto` пробует Word, затем LibreOffice, затем OpenOffice. Без офиса: `--table-continuation off` (и то же для листингов).
|
||||
|
||||
## Меню GUI
|
||||
|
||||
|
||||
+3
-2
@@ -71,8 +71,9 @@
|
||||
|
||||
- `^` — rowspan (продолжение ячейки сверху)
|
||||
- `>` — colspan (продолжение слева)
|
||||
- `<` — то же, и можно в строке заголовков (заголовок на всю ширину)
|
||||
|
||||
Не ставить `^`/`>` в заголовочной строке; `>` — не в первом столбце. Графу «№ п/п» не добавлять.
|
||||
Не ставить `^`/`>` в заголовочной строке; `<` в шапке можно. `<` и `>` — не в первом столбце. Графу «№ п/п» не добавлять.
|
||||
|
||||
## Листинг
|
||||
|
||||
@@ -89,7 +90,7 @@ def f():
|
||||
|
||||
## Диаграмма → Рисунок
|
||||
|
||||
Перед блоком — `%id Подпись`. Языки: `uml`, `plantuml`, `mermaid`/`mmd`, `idef0`, `dfd`/`data-flow-diagram` (Graphviz вшитый/PATH или Kroki), или id схемы (`c4`, `usecase`, …).
|
||||
Перед блоком — `%id Подпись`. Языки: `uml`, `plantuml`, `mermaid`/`mmd`, `idef0`, `dfd`/`data-flow-diagram` (Graphviz вшитый/PATH или Kroki), `dfd-gs`/`gane-sarson` (Гейн–Сарсон, локальный Pillow), или id схемы (`c4`, `usecase`, …).
|
||||
|
||||
- `+listing` — ещё и Листинг с исходником
|
||||
- `+landscape` — альбомная страница под широкий рисунок/таблицу
|
||||
|
||||
+25
-3
@@ -33,11 +33,12 @@ Rel(user, app, "логин")
|
||||
| `` ```uml-<id> `` / `` ```<id> `` | Схема из `md2gost.schemes.json` (обёртка и `!include` добавляются сами) |
|
||||
| `` ```mermaid `` / `` ```mmd `` | Mermaid: браузер / QuickJS / Kroki |
|
||||
| `` ```idef0 `` / `` ```uml-idef0 `` | IDEF0 (FIPS 183): локальный рендер, без PlantUML / Kroki |
|
||||
| `` ```dfd `` / `` ```uml-dfd `` / `` ```data-flow-diagram `` | DFD ([pbauermeister/dfd](https://github.com/pbauermeister/dfd)): вшитый Graphviz / PATH / Kroki |
|
||||
| `` ```dfd `` / `` ```uml-dfd `` / `` ```data-flow-diagram `` | DFD Йордона ([pbauermeister/dfd](https://github.com/pbauermeister/dfd)): вшитый Graphviz / PATH / Kroki |
|
||||
| `` ```dfd-gs `` / `` ```gane-sarson `` | DFD Гейна–Сарсона: локальный Pillow, без Graphviz |
|
||||
|
||||
## Встроенные схемы
|
||||
|
||||
Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске встроенные схемы (`author: md2gost`) синхронизируются с шаблоном: новые id добавляются, устаревшие (например удалённый `bpmn`) убираются, содержимое обновляется. Схемы с пустым или другим `author` не трогаются. Если правите встроенную в GUI и сохраняете — `author` сбрасывается, чтобы миграция не затёрла правки.
|
||||
Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске в файл добавляются только отсутствующие встроенные схемы. Уже записанные схемы не перезаписываются и не удаляются: правки и свои шаблоны сохраняются. Файл, который не удалось разобрать, не затирается. Если правите встроенную схему в GUI и сохраняете — `author` сбрасывается, схема помечается как ваша.
|
||||
|
||||
| id | Название | Оградка |
|
||||
|----|----------|---------|
|
||||
@@ -202,6 +203,27 @@ Ship --> Client уведомление
|
||||
|
||||
Зависимости: `data-flow-diagram` идёт с md2gost (Python ≥3.11). Без Graphviz офлайн DFD не нарисуется — либо кнопка «Скачать Graphviz» / `scripts/fetch_graphviz.py`, либо `--diagram-fallback remote` → kroki.io.
|
||||
|
||||
## DFD Гейна–Сарсона
|
||||
|
||||
Оградка `` ```dfd-gs `` (то же: `` ```gane-sarson ``, `` ```uml-dfd-gs ``). Те же `entity` / `process` / `store` и `A --> B подпись`, фигуры как в Ramus: сущность с тенью, скруглённый процесс, хранилище с полосой `D1`. Шапка: `title`, `node`, `number`, `author`, `project`, `date`, `rev`, `status`, `used_at`. `form plain` убирает штамп. Рендер локальный (Pillow), Graphviz не нужен. Колонки: сущности, процессы, хранилища.
|
||||
|
||||
````markdown
|
||||
%dfd_gs Приёмка работы
|
||||
|
||||
```dfd-gs
|
||||
title Зафиксировать решение приёмки
|
||||
node A44
|
||||
number 1
|
||||
|
||||
entity Artist Художник
|
||||
process 1 Принять файлы результата
|
||||
store D2 Файлы работ
|
||||
|
||||
Artist --> 1 файлы работы
|
||||
1 --> D2 превью и хеш
|
||||
```
|
||||
````
|
||||
|
||||
## Файл схем: поля
|
||||
|
||||
```json
|
||||
@@ -269,4 +291,4 @@ id схемы: латиница, цифры, `_`; начинается с бук
|
||||
2. Или править `md2gost.schemes.json` вручную («Открыть JSON»).
|
||||
3. CLI: `--schemes путь.json`.
|
||||
|
||||
Чтобы правка встроенной схемы не сбрасывалась при обновлении приложения, оставьте пустой `author` (GUI делает это сама при сохранении изменённой встроенной схемы).
|
||||
Запуск программы не сбрасывает правки в `md2gost.schemes.json`. В GUI кнопка «Добавить» сразу записывает новую схему (`custom1`, `custom2`, …) в этот файл.
|
||||
|
||||
@@ -30,6 +30,8 @@ datas = [
|
||||
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
|
||||
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
|
||||
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
|
||||
(str(ROOT / "md2gost" / "office" / "uno_worker.py"), "md2gost/office"),
|
||||
(str(ROOT / "md2gost" / "office" / "uno_helpers.py"), "md2gost/office"),
|
||||
(str(ROOT / "prompts"), "prompts"),
|
||||
(str(ROOT / "docs"), "docs"),
|
||||
]
|
||||
|
||||
@@ -19,6 +19,8 @@ datas = [
|
||||
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
|
||||
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
|
||||
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
|
||||
(str(ROOT / "md2gost" / "office" / "uno_worker.py"), "md2gost/office"),
|
||||
(str(ROOT / "md2gost" / "office" / "uno_helpers.py"), "md2gost/office"),
|
||||
(str(ROOT / "prompts"), "prompts"),
|
||||
(str(ROOT / "docs"), "docs"),
|
||||
]
|
||||
|
||||
+8
-3
@@ -132,28 +132,33 @@ python -m md2gost report.md -o report.docx --toc manual
|
||||
|
||||
Наша оценка высоты строк ≠ вёрстка Word → если резать таблицу в скрипте, получается
|
||||
mid-page «Продолжение…» (как было на 2.4). По умолчанию режим **`word`**: после save
|
||||
Word COM режет по реальной пагинации. Без Word — укажите `off` или поставьте Word + pywin32.
|
||||
живой движок вёрстки режет по реальной пагинации (`--office-engine`, по умолчанию `auto`:
|
||||
Word COM → LibreOffice UNO → OpenOffice UNO). Без офиса — `off`.
|
||||
|
||||
| Режим | Поведение |
|
||||
|--------|-----------|
|
||||
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). Таблицы с вертикальным merge (`^`): Word не умеет `Split` при vMerge — merge временно снимается, после разреза восстанавливается в обоих фрагментах |
|
||||
| **`word`** (по умолчанию) | Как `off` при рендере; после save: `Split` + «Продолжение Таблицы N» через Word COM или LibreOffice/OpenOffice UNO (`--office-engine`). Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). В Word при vMerge (`^`): merge временно снимается, после разреза восстанавливается в обоих фрагментах |
|
||||
| **`off`** / **`soft`** | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
|
||||
| **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) |
|
||||
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --table-continuation word
|
||||
python -m md2gost report.md -o report.docx --table-continuation word --office-engine libre
|
||||
python -m md2gost report.md -o report.docx --table-continuation word --office-engine openoffice
|
||||
python -m md2gost report.md -o report.docx --table-continuation off
|
||||
python -m md2gost report.md -o report.docx --table-continuation caption
|
||||
```
|
||||
|
||||
OpenOffice/LibreOffice UNO используют постоянный профиль `%LOCALAPPDATA%\md2gost\office-profiles\` (без мастера регистрации на каждый запуск). Если OpenOffice всё же просит регистрацию один раз — завершите её; дальше профиль сохранится.
|
||||
|
||||
### Продолжение листинга (`--listing-continuation`)
|
||||
|
||||
Те же режимы, что у таблиц. По умолчанию **`word`**.
|
||||
|
||||
| Режим | Поведение |
|
||||
|--------|-----------|
|
||||
| **`word`** (по умолчанию) | После save Word COM + «Продолжение Листинга N» (Windows + Word + pywin32) |
|
||||
| **`word`** (по умолчанию) | После save + «Продолжение Листинга N» (Word COM или LibreOffice/OpenOffice UNO) |
|
||||
| **`off`** / **`soft`** | Один блок кода; пагинацию делает Word. «Продолжение…» — вручную в markdown, если нужно |
|
||||
| **`legacy`** | Режем по оценке высоты + «Продолжение Листинга N» с `page_break_before` |
|
||||
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение Листинга N» |
|
||||
|
||||
+18
-4
@@ -4,6 +4,7 @@ import sys
|
||||
|
||||
from .doc_metadata import AUTHOR_SOURCES, DEFAULT_DOC_COMMENTS
|
||||
from .pipeline import ConvertRequest, convert, should_launch_gui
|
||||
from .office import DEFAULT_OFFICE_ENGINE, OFFICE_ENGINE_MODES
|
||||
from .profiles import (
|
||||
DEFAULT_HEADING_NUMBERING,
|
||||
DEFAULT_TABLE_CONTINUATION,
|
||||
@@ -69,8 +70,8 @@ def build_parser() -> ArgumentParser:
|
||||
default=DEFAULT_TABLE_CONTINUATION,
|
||||
help=(
|
||||
"Таблицы длиннее страницы: "
|
||||
"word — после сохранения Word COM режет по реальной пагинации + «Продолжение…» "
|
||||
"(по умолчанию; нужны Windows, Word, pywin32); "
|
||||
"word — после сохранения режет по реальной пагинации + «Продолжение…» "
|
||||
"(по умолчанию; движок: --office-engine auto|word|libre|openoffice); "
|
||||
"off/soft — одна таблица, пагинация Word, без авто«Продолжение»; "
|
||||
"legacy/caption — режем по оценке высоты и вставляем «Продолжение…» "
|
||||
"(оценка ≠ Word, возможны артефакты). "
|
||||
@@ -83,8 +84,8 @@ def build_parser() -> ArgumentParser:
|
||||
default=DEFAULT_LISTING_CONTINUATION,
|
||||
help=(
|
||||
"Листинги длиннее страницы: "
|
||||
"word — после сохранения Word COM + «Продолжение Листинга…» "
|
||||
"(по умолчанию; нужны Windows, Word, pywin32); "
|
||||
"word — после сохранения + «Продолжение Листинга…» "
|
||||
"(по умолчанию; движок: --office-engine auto|word|libre|openoffice); "
|
||||
"off/soft — один блок, пагинация Word, без авто«Продолжение»; "
|
||||
"legacy/caption — режем по оценке высоты и вставляем «Продолжение Листинга…» "
|
||||
"(оценка ≠ Word, возможны артефакты). "
|
||||
@@ -100,6 +101,18 @@ def build_parser() -> ArgumentParser:
|
||||
"после разрыва. По умолчанию выкл."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--office-engine",
|
||||
choices=OFFICE_ENGINE_MODES,
|
||||
default=DEFAULT_OFFICE_ENGINE,
|
||||
help=(
|
||||
"Движок пост-разрыва таблиц/листингов при --table-continuation word: "
|
||||
"auto — Word, иначе LibreOffice, иначе OpenOffice; "
|
||||
"word — только Microsoft Word COM (Windows + pywin32); "
|
||||
"libre / openoffice — UNO Writer (soffice + program/python). "
|
||||
f"По умолчанию: {DEFAULT_OFFICE_ENGINE}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--emdash-to-hyphen",
|
||||
action=BooleanOptionalAction,
|
||||
@@ -280,6 +293,7 @@ def request_from_args(args) -> ConvertRequest:
|
||||
table_continuation=args.table_continuation,
|
||||
listing_continuation=args.listing_continuation,
|
||||
table_repeat_header=bool(getattr(args, "table_repeat_header", False)),
|
||||
office_engine=getattr(args, "office_engine", DEFAULT_OFFICE_ENGINE),
|
||||
emdash_to_hyphen=args.emdash_to_hyphen,
|
||||
hr_pagebreak=args.hr_pagebreak,
|
||||
title=args.title,
|
||||
|
||||
@@ -423,8 +423,8 @@ def check_continuation_hints(
|
||||
issues.append(Issue(
|
||||
"table.continuation", "warning",
|
||||
"Word сам не вставит «Продолжение Таблицы N» при переносе. "
|
||||
"Варианты: --table-continuation word (точный разрыв через Word COM) "
|
||||
"или caption (оценка высоты ≠ вёрстка Word, возможны артефакты).",
|
||||
"Варианты: --table-continuation word (Word COM или LibreOffice/OpenOffice UNO; "
|
||||
"см. --office-engine) или caption (оценка высоты ≠ вёрстка, возможны артефакты).",
|
||||
))
|
||||
# Long fenced code blocks (likely listings)
|
||||
long_listing = False
|
||||
@@ -454,7 +454,7 @@ _TABLE_BLOCK_RE = re.compile(
|
||||
r"(?:^[ \t]*\|.+\|[ \t]*\n?)*",
|
||||
re.M,
|
||||
)
|
||||
_MERGE_ONLY_RE = re.compile(r"^\s*(\^{1,2}|>{1,2})\s*$")
|
||||
_MERGE_ONLY_RE = re.compile(r"^\s*(\^{1,2}|>{1,2}|<{1,2})\s*$")
|
||||
|
||||
|
||||
def _split_table_row(line: str) -> list[str]:
|
||||
@@ -467,7 +467,11 @@ def _split_table_row(line: str) -> list[str]:
|
||||
|
||||
|
||||
def check_table_merge(text: str) -> list[Issue]:
|
||||
"""Validate ^ / > merge markers in pipe tables."""
|
||||
"""Validate ^ / > / < merge markers in pipe tables.
|
||||
|
||||
`<` continues the cell on the left, same as `>`, and may sit in the header
|
||||
so a title can span the row. `^` and `>` stay forbidden in the header.
|
||||
"""
|
||||
issues: list[Issue] = []
|
||||
for tm in _TABLE_BLOCK_RE.finditer(text):
|
||||
block = tm.group(0)
|
||||
@@ -481,8 +485,18 @@ def check_table_merge(text: str) -> list[Issue]:
|
||||
n_cols = len(header)
|
||||
header_line = _line_of(text, block_start)
|
||||
|
||||
for cell in header:
|
||||
if _MERGE_ONLY_RE.match(cell):
|
||||
for c_i, cell in enumerate(header):
|
||||
m = _MERGE_ONLY_RE.match(cell)
|
||||
if not m:
|
||||
continue
|
||||
if m.group(1)[:1] in (">", "<") and c_i == 0:
|
||||
marker = m.group(1)[:1]
|
||||
issues.append(Issue(
|
||||
"table.merge_col", "error",
|
||||
f"Маркер «{marker}» нельзя ставить в первом столбце (нет ячейки слева)",
|
||||
header_line,
|
||||
))
|
||||
elif not m.group(1).startswith("<"):
|
||||
issues.append(Issue(
|
||||
"table.merge_header", "error",
|
||||
"Маркер склеивания ^/> нельзя использовать в первой (заголовочной) строке таблицы",
|
||||
@@ -497,10 +511,11 @@ def check_table_merge(text: str) -> list[Issue]:
|
||||
m = _MERGE_ONLY_RE.match(cell)
|
||||
if not m:
|
||||
continue
|
||||
if m.group(1).startswith(">") and c_i == 0:
|
||||
if m.group(1)[:1] in (">", "<") and c_i == 0:
|
||||
marker = m.group(1)[:1]
|
||||
issues.append(Issue(
|
||||
"table.merge_col", "error",
|
||||
"Маркер «>» нельзя ставить в первом столбце (нет ячейки слева)",
|
||||
f"Маркер «{marker}» нельзя ставить в первом столбце (нет ячейки слева)",
|
||||
line_no,
|
||||
))
|
||||
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
"""DFD в нотации Гейна–Сарсона (лист Ramus).
|
||||
|
||||
Оградка ```dfd-gs (алиасы ```gane-sarson, ```uml-dfd-gs, ```uml-gane-sarson).
|
||||
Локальный Pillow/SVG, без Graphviz. Йордон остаётся в ```dfd.
|
||||
|
||||
Раскладка: сущности слева, процессы по центру, хранилища справа.
|
||||
Стрелки между соседними колонками идут по зазору. Сквозной поток через
|
||||
промежуточную колонку может пересечь блок — обхода препятствий нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import xml.sax.saxutils
|
||||
from dataclasses import dataclass, field
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .idef0 import _load_font, _text_wh, _wrap
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
CACHE_KEY_PREFIX = "dfd-gs-v1\n"
|
||||
|
||||
_META = {
|
||||
"title": "title",
|
||||
"node": "node",
|
||||
"number": "number",
|
||||
"author": "author",
|
||||
"project": "project",
|
||||
"date": "date",
|
||||
"rev": "rev",
|
||||
"revision": "rev",
|
||||
"status": "status",
|
||||
"used_at": "used_at",
|
||||
"used at": "used_at",
|
||||
"used-at": "used_at",
|
||||
"form": "form",
|
||||
}
|
||||
_META_ALIASES = sorted(_META, key=len, reverse=True)
|
||||
|
||||
_KINDS = ("entity", "process", "store")
|
||||
_KIND_W = {"entity": 200.0, "process": 248.0, "store": 256.0}
|
||||
_KIND_MIN_H = {"entity": 78.0, "process": 96.0, "store": 70.0}
|
||||
|
||||
|
||||
class DfdGsParseError(ValueError):
|
||||
"""Неверный DSL DFD Гейна–Сарсона."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GsNode:
|
||||
kind: str
|
||||
id: str
|
||||
label: str
|
||||
badge: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GsFlow:
|
||||
src: str
|
||||
dst: str
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GsDiagram:
|
||||
title: str = ""
|
||||
node: str = ""
|
||||
number: str = ""
|
||||
author: str = ""
|
||||
project: str = ""
|
||||
date: str = ""
|
||||
rev: str = ""
|
||||
status: str = ""
|
||||
used_at: str = ""
|
||||
form: str = "kit"
|
||||
nodes: list[GsNode] = field(default_factory=list)
|
||||
flows: list[GsFlow] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Placed:
|
||||
node: GsNode
|
||||
col: int
|
||||
x: float
|
||||
y: float
|
||||
w: float
|
||||
h: float
|
||||
|
||||
@property
|
||||
def r(self) -> float:
|
||||
return self.x + self.w
|
||||
|
||||
@property
|
||||
def b(self) -> float:
|
||||
return self.y + self.h
|
||||
|
||||
@property
|
||||
def cx(self) -> float:
|
||||
return self.x + self.w / 2
|
||||
|
||||
@property
|
||||
def cy(self) -> float:
|
||||
return self.y + self.h / 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Route:
|
||||
flow: GsFlow
|
||||
pts: list[tuple[float, float]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Layout:
|
||||
width: int
|
||||
height: int
|
||||
frame: tuple[float, float, float, float] | None
|
||||
placed: dict[str, _Placed]
|
||||
routes: list[_Route]
|
||||
|
||||
|
||||
def _is_proc_num(token: str) -> bool:
|
||||
if not token or token[0] not in "0123456789":
|
||||
return False
|
||||
parts = token.split(".")
|
||||
return all(part.isdigit() and part != "" for part in parts)
|
||||
|
||||
|
||||
def _is_store_code(token: str) -> bool:
|
||||
if len(token) < 2 or token[0] not in "Dd":
|
||||
return False
|
||||
return token[1:].isdigit()
|
||||
|
||||
|
||||
def _meta_of(line: str) -> tuple[str, str] | None:
|
||||
low = line.lower()
|
||||
for alias in _META_ALIASES:
|
||||
if low.startswith(alias) and (len(line) == len(alias) or line[len(alias)].isspace()):
|
||||
return _META[alias], line[len(alias):].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _assign_badges(nodes: list[GsNode]) -> None:
|
||||
processes = [n for n in nodes if n.kind == "process"]
|
||||
for index, node in enumerate(processes, 1):
|
||||
node.badge = node.id if _is_proc_num(node.id) else str(index)
|
||||
|
||||
stores = [n for n in nodes if n.kind == "store"]
|
||||
used = {n.id.upper() for n in stores if _is_store_code(n.id)}
|
||||
seq = 1
|
||||
for node in stores:
|
||||
if _is_store_code(node.id):
|
||||
node.badge = node.id.upper()
|
||||
continue
|
||||
while f"D{seq}" in used:
|
||||
seq += 1
|
||||
node.badge = f"D{seq}"
|
||||
used.add(node.badge)
|
||||
seq += 1
|
||||
|
||||
entities = [n for n in nodes if n.kind == "entity"]
|
||||
for index, node in enumerate(entities, 1):
|
||||
node.badge = str(index)
|
||||
|
||||
|
||||
def parse_dfd_gs(source: str) -> GsDiagram:
|
||||
"""Разобрать DSL. Пустой текст и неизвестные узлы — DfdGsParseError."""
|
||||
text = (source or "").lstrip("\ufeff")
|
||||
if not text.strip():
|
||||
raise DfdGsParseError("пустой DFD")
|
||||
|
||||
diagram = GsDiagram()
|
||||
seen: dict[str, int] = {}
|
||||
for lineno, raw in enumerate(text.splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
continue
|
||||
|
||||
kind = next((k for k in _KINDS if line.lower().startswith(k) and (
|
||||
len(line) == len(k) or line[len(k)].isspace()
|
||||
)), "")
|
||||
if kind:
|
||||
rest = line[len(kind):].strip()
|
||||
parts = rest.split(None, 1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
raise DfdGsParseError(f"строка {lineno}: у {kind} нет подписи")
|
||||
node_id, label = parts[0], parts[1].strip()
|
||||
if node_id in seen:
|
||||
raise DfdGsParseError(
|
||||
f"строка {lineno}: повтор идентификатора «{node_id}»"
|
||||
)
|
||||
seen[node_id] = lineno
|
||||
diagram.nodes.append(GsNode(kind, node_id, label))
|
||||
continue
|
||||
|
||||
if "-->" in line:
|
||||
left, _, right = line.partition("-->")
|
||||
src = left.strip()
|
||||
tail = right.strip()
|
||||
if not src or not tail:
|
||||
raise DfdGsParseError(f"строка {lineno}: неполный поток")
|
||||
dst, _, label = tail.partition(" ")
|
||||
label = label.strip()
|
||||
if label.startswith(":"):
|
||||
label = label[1:].strip()
|
||||
if not dst:
|
||||
raise DfdGsParseError(f"строка {lineno}: неполный поток")
|
||||
diagram.flows.append(GsFlow(src, dst, label))
|
||||
continue
|
||||
|
||||
meta = _meta_of(line)
|
||||
if meta:
|
||||
key, value = meta
|
||||
if key == "form":
|
||||
form = value.lower()
|
||||
if form not in {"kit", "plain"}:
|
||||
raise DfdGsParseError(
|
||||
f"строка {lineno}: form «{value}» — ожидается kit или plain"
|
||||
)
|
||||
diagram.form = form
|
||||
else:
|
||||
setattr(diagram, key, value)
|
||||
continue
|
||||
|
||||
raise DfdGsParseError(f"строка {lineno}: неизвестная запись «{line}»")
|
||||
|
||||
if not diagram.nodes:
|
||||
raise DfdGsParseError("DFD без узлов")
|
||||
|
||||
for flow in diagram.flows:
|
||||
if flow.src == flow.dst:
|
||||
raise DfdGsParseError(f"поток из «{flow.src}» в себя")
|
||||
for end in (flow.src, flow.dst):
|
||||
if end not in seen:
|
||||
raise DfdGsParseError(f"неизвестный узел «{end}»")
|
||||
|
||||
_assign_badges(diagram.nodes)
|
||||
return diagram
|
||||
|
||||
|
||||
# --- scene -----------------------------------------------------------------
|
||||
|
||||
def _svg_anchor(anchor: str) -> tuple[str, str, str]:
|
||||
a = (anchor or "lt").lower()
|
||||
horiz = a[0] if a else "l"
|
||||
vert = a[1] if len(a) > 1 else "t"
|
||||
ta = {"l": "start", "m": "middle", "r": "end"}.get(horiz, "start")
|
||||
dy = {"t": "0.9em", "m": "0.35em", "b": "0"}.get(vert, "0.9em")
|
||||
return ta, "0", dy
|
||||
|
||||
|
||||
class _Scene:
|
||||
def __init__(self, width: int, height: int):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.ops: list[tuple] = []
|
||||
|
||||
def line(self, x1, y1, x2, y2, width: float = 1.4) -> None:
|
||||
self.ops.append(("line", float(x1), float(y1), float(x2), float(y2), float(width)))
|
||||
|
||||
def polyline(self, pts: list[tuple[float, float]], width: float = 1.4) -> None:
|
||||
if len(pts) < 2:
|
||||
return
|
||||
self.ops.append(("polyline", [(float(x), float(y)) for x, y in pts], float(width)))
|
||||
|
||||
def rect(
|
||||
self,
|
||||
x, y, w, h,
|
||||
width: float = 1.4,
|
||||
fill: str | None = None,
|
||||
outline: str | None = "#000000",
|
||||
) -> None:
|
||||
self.ops.append(
|
||||
("rect", float(x), float(y), float(w), float(h), float(width), fill, outline)
|
||||
)
|
||||
|
||||
def roundrect(
|
||||
self, x, y, w, h, radius: float,
|
||||
width: float = 1.4,
|
||||
fill: str | None = "#ffffff",
|
||||
outline: str | None = "#000000",
|
||||
) -> None:
|
||||
self.ops.append((
|
||||
"roundrect", float(x), float(y), float(w), float(h), float(radius),
|
||||
float(width), fill, outline,
|
||||
))
|
||||
|
||||
def polygon(self, pts: list[tuple[float, float]], fill: str = "#000000") -> None:
|
||||
if len(pts) < 3:
|
||||
return
|
||||
self.ops.append(("polygon", [(float(x), float(y)) for x, y in pts], fill))
|
||||
|
||||
def text(
|
||||
self, x, y, text: str, size: float, *,
|
||||
bold: bool = False, anchor: str = "lt", fill: str = "#000000",
|
||||
) -> None:
|
||||
if not text:
|
||||
return
|
||||
self.ops.append(("text", float(x), float(y), text, float(size), bold, anchor, fill))
|
||||
|
||||
def text_rot(
|
||||
self, x, y, text: str, size: float, *,
|
||||
bold: bool = False, angle: float = 90,
|
||||
) -> None:
|
||||
if not text:
|
||||
return
|
||||
self.ops.append(("text_rot", float(x), float(y), text, float(size), bold, float(angle)))
|
||||
|
||||
def to_png(self) -> bytes:
|
||||
img = Image.new("RGB", (self.width, self.height), "#ffffff")
|
||||
draw = ImageDraw.Draw(img)
|
||||
for op in self.ops:
|
||||
kind = op[0]
|
||||
if kind == "line":
|
||||
_, x1, y1, x2, y2, w = op
|
||||
draw.line([(x1, y1), (x2, y2)], fill="#000000", width=max(1, int(round(w))))
|
||||
elif kind == "polyline":
|
||||
_, pts, w = op
|
||||
draw.line(pts, fill="#000000", width=max(1, int(round(w))))
|
||||
elif kind == "rect":
|
||||
_, x, y, w, h, lw, fill, outline = op
|
||||
stroke = max(1, int(round(lw))) if outline else 0
|
||||
draw.rectangle([x, y, x + w, y + h], fill=fill, outline=outline, width=stroke)
|
||||
elif kind == "roundrect":
|
||||
_, x, y, w, h, radius, lw, fill, outline = op
|
||||
stroke = max(1, int(round(lw))) if outline else 0
|
||||
rad = max(0, min(radius, w / 2 - 1, h / 2 - 1))
|
||||
draw.rounded_rectangle(
|
||||
[x, y, x + w, y + h], radius=rad, fill=fill, outline=outline, width=stroke,
|
||||
)
|
||||
elif kind == "polygon":
|
||||
_, pts, fill = op
|
||||
draw.polygon(pts, fill=fill)
|
||||
elif kind == "text":
|
||||
_, x, y, text, size, bold, anchor, fill = op
|
||||
font = _load_font(int(round(size)), bold)
|
||||
draw.text((x, y), text, font=font, fill=fill, anchor=anchor)
|
||||
elif kind == "text_rot":
|
||||
_, x, y, text, size, bold, angle = op
|
||||
_paste_rotated(img, text, x, y, int(round(size)), bold, angle)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
def to_svg(self) -> str:
|
||||
esc = xml.sax.saxutils.escape
|
||||
parts = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{self.width}" height="{self.height}" '
|
||||
f'viewBox="0 0 {self.width} {self.height}">',
|
||||
'<rect width="100%" height="100%" fill="#fff"/>',
|
||||
]
|
||||
for op in self.ops:
|
||||
kind = op[0]
|
||||
if kind == "line":
|
||||
_, x1, y1, x2, y2, w = op
|
||||
parts.append(
|
||||
f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
|
||||
f'stroke="#000" stroke-width="{w:.2f}"/>'
|
||||
)
|
||||
elif kind == "polyline":
|
||||
_, pts, w = op
|
||||
d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
|
||||
parts.append(
|
||||
f'<polyline points="{d}" fill="none" stroke="#000" '
|
||||
f'stroke-width="{w:.2f}" stroke-linejoin="miter"/>'
|
||||
)
|
||||
elif kind == "rect":
|
||||
_, x, y, w, h, lw, fill, outline = op
|
||||
parts.append(
|
||||
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
||||
f'fill="{fill or "none"}" stroke="{outline or "none"}" '
|
||||
f'stroke-width="{0 if outline is None else lw:.2f}"/>'
|
||||
)
|
||||
elif kind == "roundrect":
|
||||
_, x, y, w, h, radius, lw, fill, outline = op
|
||||
parts.append(
|
||||
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
||||
f'rx="{radius:.1f}" ry="{radius:.1f}" fill="{fill or "none"}" '
|
||||
f'stroke="{outline or "none"}" '
|
||||
f'stroke-width="{0 if outline is None else lw:.2f}"/>'
|
||||
)
|
||||
elif kind == "polygon":
|
||||
_, pts, fill = op
|
||||
d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
|
||||
parts.append(f'<polygon points="{d}" fill="{fill}"/>')
|
||||
elif kind == "text":
|
||||
_, x, y, text, size, bold, anchor, fill = op
|
||||
ta, dx, dy = _svg_anchor(anchor)
|
||||
weight = "700" if bold else "400"
|
||||
parts.append(
|
||||
f'<text x="{x:.1f}" y="{y:.1f}" dx="{dx}" dy="{dy}" text-anchor="{ta}" '
|
||||
f'font-family="Arial, DejaVu Sans, sans-serif" font-size="{size:.1f}" '
|
||||
f'font-weight="{weight}" fill="{fill}">{esc(text)}</text>'
|
||||
)
|
||||
elif kind == "text_rot":
|
||||
_, x, y, text, size, bold, angle = op
|
||||
weight = "700" if bold else "400"
|
||||
parts.append(
|
||||
f'<text x="{x:.1f}" y="{y:.1f}" text-anchor="middle" '
|
||||
f'dominant-baseline="middle" '
|
||||
f'transform="rotate({-angle:.0f} {x:.1f} {y:.1f})" '
|
||||
f'font-family="Arial, DejaVu Sans, sans-serif" font-size="{size:.1f}" '
|
||||
f'font-weight="{weight}" fill="#000">{esc(text)}</text>'
|
||||
)
|
||||
parts.append("</svg>")
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def _paste_rotated(
|
||||
base: Image.Image, text: str, cx: float, cy: float,
|
||||
size: int, bold: bool, angle: float,
|
||||
) -> None:
|
||||
font = _load_font(size, bold)
|
||||
left, top, right, bottom = font.getbbox(text)
|
||||
pad = 2
|
||||
patch = Image.new("RGBA", (right - left + pad * 2, bottom - top + pad * 2), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(patch).text((pad - left, pad - top), text, font=font, fill=(0, 0, 0, 255))
|
||||
rotated = patch.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC)
|
||||
base.paste(
|
||||
rotated,
|
||||
(int(round(cx - rotated.width / 2)), int(round(cy - rotated.height / 2))),
|
||||
rotated,
|
||||
)
|
||||
|
||||
|
||||
# --- layout ----------------------------------------------------------------
|
||||
|
||||
def _node_size(node: GsNode, scale: float) -> tuple[float, float]:
|
||||
s = scale
|
||||
width = _KIND_W[node.kind] * s
|
||||
font = _load_font(int(round(15 * s)), False)
|
||||
if node.kind == "store":
|
||||
inner = width - 44 * s
|
||||
else:
|
||||
inner = width - 28 * s
|
||||
lines = _wrap(node.label, font, max(40, int(inner)))
|
||||
line_h = 18 * s
|
||||
text_h = max(1, len(lines)) * line_h
|
||||
extra = 30 * s if node.kind == "process" else 22 * s
|
||||
height = max(_KIND_MIN_H[node.kind] * s, text_h + extra)
|
||||
return width, height
|
||||
|
||||
|
||||
def _simplify(pts: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
out: list[tuple[float, float]] = []
|
||||
for point in pts:
|
||||
if out and abs(point[0] - out[-1][0]) < 0.4 and abs(point[1] - out[-1][1]) < 0.4:
|
||||
continue
|
||||
if len(out) >= 2:
|
||||
a, b = out[-2], out[-1]
|
||||
horiz = abs(a[1] - b[1]) < 0.4 and abs(b[1] - point[1]) < 0.4
|
||||
vert = abs(a[0] - b[0]) < 0.4 and abs(b[0] - point[0]) < 0.4
|
||||
if horiz or vert:
|
||||
out[-1] = point
|
||||
continue
|
||||
out.append(point)
|
||||
return out
|
||||
|
||||
|
||||
def _slot_xs(left: float, right: float, n: int) -> list[float]:
|
||||
if n <= 0:
|
||||
return []
|
||||
if right < left:
|
||||
left, right = right, left
|
||||
span = right - left
|
||||
margin = min(6.0, span / 4) if span else 0.0
|
||||
a, b = left + margin, right - margin
|
||||
if b <= a or n == 1:
|
||||
return [(left + right) / 2] * n
|
||||
return [a + (b - a) * (k + 1) / (n + 1) for k in range(n)]
|
||||
|
||||
|
||||
def _arrange(diagram: GsDiagram, scale: float) -> _Layout:
|
||||
s = max(float(scale), 1.0)
|
||||
groups: list[list[GsNode]] = []
|
||||
for kind in _KINDS:
|
||||
chunk = [n for n in diagram.nodes if n.kind == kind]
|
||||
if chunk:
|
||||
groups.append(chunk)
|
||||
|
||||
sizes = {n.id: _node_size(n, s) for n in diagram.nodes}
|
||||
# Vertical gap fits a one-line label between stacked boxes.
|
||||
gap_y = 50 * s
|
||||
# Horizontal gap fits a wrapped flow label without covering the boxes.
|
||||
gap_x = 132 * s
|
||||
col_w = [max(sizes[n.id][0] for n in group) for group in groups]
|
||||
col_h = []
|
||||
for group in groups:
|
||||
heights = [sizes[n.id][1] for n in group]
|
||||
col_h.append(sum(heights) + gap_y * (len(group) - 1))
|
||||
|
||||
col_of = {n.id: i for i, group in enumerate(groups) for n in group}
|
||||
index_in_col = {
|
||||
node.id: (col_i, pos)
|
||||
for col_i, group in enumerate(groups)
|
||||
for pos, node in enumerate(group)
|
||||
}
|
||||
|
||||
def _neighbors(flow: GsFlow) -> bool:
|
||||
a, b = index_in_col[flow.src], index_in_col[flow.dst]
|
||||
return a[0] == b[0] and abs(a[1] - b[1]) == 1
|
||||
|
||||
last = len(groups) - 1
|
||||
needs_channel = any(
|
||||
col_of[flow.src] == col_of[flow.dst] == last and not _neighbors(flow)
|
||||
for flow in diagram.flows
|
||||
)
|
||||
extra_r = 56 * s if needs_channel else 0.0
|
||||
|
||||
content_w = sum(col_w) + gap_x * (len(groups) - 1) + extra_r
|
||||
content_h = max(col_h) if col_h else 0.0
|
||||
inner_pad = 24 * s
|
||||
outer = 8 * s
|
||||
kit = diagram.form != "plain"
|
||||
header_h = 76 * s if kit else 0.0
|
||||
footer_h = 56 * s if kit else 0.0
|
||||
need_w = content_w + 2 * inner_pad
|
||||
need_h = content_h + 2 * inner_pad
|
||||
# Stamp needs a readable width; height follows the columns, not a tall empty sheet.
|
||||
width = max((760 if kit else 480) * s, need_w + 2 * outer)
|
||||
height = need_h + header_h + footer_h + 2 * outer
|
||||
width_i, height_i = int(round(width)), int(round(height))
|
||||
|
||||
frame = (outer, outer, width - 2 * outer, height - 2 * outer)
|
||||
if kit:
|
||||
work_x, work_y = frame[0], frame[1] + header_h
|
||||
work_w, work_h = frame[2], frame[3] - header_h - footer_h
|
||||
else:
|
||||
work_x, work_y, work_w, work_h = frame
|
||||
|
||||
block_w = content_w
|
||||
ox = work_x + (work_w - block_w) / 2
|
||||
oy = work_y + max(inner_pad, (work_h - content_h) / 2)
|
||||
|
||||
placed: dict[str, _Placed] = {}
|
||||
col_box: list[tuple[float, float]] = []
|
||||
x = ox
|
||||
for index, group in enumerate(groups):
|
||||
col_box.append((x, col_w[index]))
|
||||
y = oy + (content_h - col_h[index]) / 2
|
||||
for node in group:
|
||||
nh = sizes[node.id][1]
|
||||
placed[node.id] = _Placed(node, index, x, y, col_w[index], nh)
|
||||
y += nh + gap_y
|
||||
x += col_w[index] + gap_x
|
||||
|
||||
gaps = [
|
||||
(col_box[i][0] + col_box[i][1], col_box[i + 1][0])
|
||||
for i in range(len(col_box) - 1)
|
||||
]
|
||||
|
||||
# Side each flow touches. Ports on one side are spread together, so a
|
||||
# pair of opposite arrows does not share the same point.
|
||||
# Neighbours stacked in one column are linked through the gap between them.
|
||||
touch: list[tuple[str, str]] = []
|
||||
side_hits: dict[tuple[str, str], list[tuple[int, str]]] = {}
|
||||
neighbor_flows: list[int] = []
|
||||
for i, flow in enumerate(diagram.flows):
|
||||
if _neighbors(flow):
|
||||
touch.append(("v", "v"))
|
||||
neighbor_flows.append(i)
|
||||
continue
|
||||
sc, dc = col_of[flow.src], col_of[flow.dst]
|
||||
if sc < dc:
|
||||
sides = ("right", "left")
|
||||
elif sc > dc:
|
||||
sides = ("left", "right")
|
||||
else:
|
||||
sides = ("right", "right")
|
||||
touch.append(sides)
|
||||
side_hits.setdefault((flow.src, sides[0]), []).append((i, "src"))
|
||||
side_hits.setdefault((flow.dst, sides[1]), []).append((i, "dst"))
|
||||
|
||||
neighbor_dx: dict[int, float] = {}
|
||||
neighbor_groups: dict[tuple[int, int, int], list[int]] = {}
|
||||
for i in neighbor_flows:
|
||||
flow = diagram.flows[i]
|
||||
col_i, pos_a = index_in_col[flow.src]
|
||||
_col_b, pos_b = index_in_col[flow.dst]
|
||||
key = (col_i, min(pos_a, pos_b), max(pos_a, pos_b))
|
||||
neighbor_groups.setdefault(key, []).append(i)
|
||||
for idxs in neighbor_groups.values():
|
||||
count = len(idxs)
|
||||
for k, fi in enumerate(idxs):
|
||||
neighbor_dx[fi] = (k - (count - 1) / 2) * 18 * s
|
||||
|
||||
port_y: dict[tuple[int, str], float] = {}
|
||||
for (nid, _side), hits in side_hits.items():
|
||||
box = placed[nid]
|
||||
ordered = sorted(
|
||||
hits,
|
||||
key=lambda hit: (
|
||||
placed[
|
||||
diagram.flows[hit[0]].dst
|
||||
if hit[1] == "src"
|
||||
else diagram.flows[hit[0]].src
|
||||
].cy,
|
||||
hit[0],
|
||||
),
|
||||
)
|
||||
n = len(ordered)
|
||||
for k, (fi, role) in enumerate(ordered):
|
||||
y = box.cy if n == 1 else box.y + box.h * (k + 1) / (n + 1)
|
||||
port_y[(fi, role)] = y
|
||||
|
||||
thresh = 10 * s
|
||||
elbow_gap: dict[tuple, list[int]] = {}
|
||||
for i, flow in enumerate(diagram.flows):
|
||||
if i in neighbor_dx:
|
||||
continue
|
||||
sc, dc = col_of[flow.src], col_of[flow.dst]
|
||||
y1, y2 = port_y[(i, "src")], port_y[(i, "dst")]
|
||||
if sc != dc and abs(y1 - y2) <= thresh:
|
||||
continue
|
||||
if sc == dc:
|
||||
elbow_gap.setdefault(("same", sc), []).append(i)
|
||||
else:
|
||||
gap_i = (dc - 1) if sc < dc else (sc - 1)
|
||||
elbow_gap.setdefault(("gap", gap_i), []).append(i)
|
||||
|
||||
gutter_x: dict[int, float] = {}
|
||||
for key, idxs in elbow_gap.items():
|
||||
ordered = sorted(idxs, key=lambda fi: (port_y[(fi, "src")], fi))
|
||||
kind, index = key
|
||||
if kind == "gap":
|
||||
left, right = gaps[index]
|
||||
else:
|
||||
left = col_box[index][0] + col_box[index][1] + 8 * s
|
||||
right = left + (40 * s if index == last else gap_x - 16 * s)
|
||||
for fi, gx in zip(ordered, _slot_xs(left, right, len(ordered))):
|
||||
gutter_x[fi] = gx
|
||||
|
||||
routes: list[_Route] = []
|
||||
for i, flow in enumerate(diagram.flows):
|
||||
src, dst = placed[flow.src], placed[flow.dst]
|
||||
if i in neighbor_dx:
|
||||
x = src.cx + neighbor_dx[i]
|
||||
if src.y < dst.y:
|
||||
pts = [(x, src.b), (x, dst.y)]
|
||||
else:
|
||||
pts = [(x, src.y), (x, dst.b)]
|
||||
routes.append(_Route(flow, pts))
|
||||
continue
|
||||
src_side, dst_side = touch[i]
|
||||
x1 = src.r if src_side == "right" else src.x
|
||||
x2 = dst.r if dst_side == "right" else dst.x
|
||||
y1, y2 = port_y[(i, "src")], port_y[(i, "dst")]
|
||||
if src.col != dst.col and abs(y1 - y2) <= thresh:
|
||||
pts = [(x1, y1), (x2, y2)]
|
||||
else:
|
||||
gx = gutter_x[i]
|
||||
pts = _simplify([(x1, y1), (gx, y1), (gx, y2), (x2, y2)])
|
||||
routes.append(_Route(flow, pts))
|
||||
|
||||
return _Layout(
|
||||
width_i,
|
||||
height_i,
|
||||
frame if kit else None,
|
||||
placed,
|
||||
routes,
|
||||
)
|
||||
|
||||
|
||||
# --- draw ------------------------------------------------------------------
|
||||
|
||||
def _draw_centered(
|
||||
scene: _Scene, text: str, x: float, y: float, w: float, h: float,
|
||||
size: float, max_width: float,
|
||||
) -> None:
|
||||
font = _load_font(int(round(size)), False)
|
||||
lines = _wrap(text, font, max(20, int(max_width)))
|
||||
line_h = size * 1.2
|
||||
block = line_h * len(lines)
|
||||
top = y + (h - block) / 2
|
||||
for i, line in enumerate(lines):
|
||||
scene.text(x + w / 2, top + i * line_h + line_h / 2, line, size, anchor="mm")
|
||||
|
||||
|
||||
def _draw_node(scene: _Scene, box: _Placed, scale: float, lw: float) -> None:
|
||||
s = scale
|
||||
node = box.node
|
||||
if node.kind == "entity":
|
||||
sh = 5 * s
|
||||
scene.rect(box.x + sh, box.y + sh, box.w, box.h, width=0, fill="#c4c4c4", outline=None)
|
||||
scene.rect(box.x, box.y, box.w, box.h, width=lw, fill="#ffffff")
|
||||
scene.text(box.x + 8 * s, box.y + 6 * s, node.badge, 13 * s, bold=True, anchor="lt")
|
||||
_draw_centered(
|
||||
scene, node.label, box.x, box.y + 16 * s, box.w, box.h - 22 * s,
|
||||
15 * s, box.w - 28 * s,
|
||||
)
|
||||
elif node.kind == "process":
|
||||
radius = min(18 * s, box.h * 0.2, box.w * 0.12)
|
||||
scene.roundrect(box.x, box.y, box.w, box.h, radius, width=lw, fill="#ffffff")
|
||||
_draw_centered(
|
||||
scene, node.label, box.x, box.y + 4 * s, box.w, box.h - 24 * s,
|
||||
15 * s, box.w - 28 * s,
|
||||
)
|
||||
scene.text(box.r - 8 * s, box.b - 6 * s, node.badge, 14 * s, bold=True, anchor="rb")
|
||||
else:
|
||||
scene.rect(box.x, box.y, box.w, box.h, width=lw, fill="#ffffff")
|
||||
strip = 28 * s
|
||||
scene.line(box.x + strip, box.y, box.x + strip, box.b, lw)
|
||||
scene.text_rot(box.x + strip / 2, box.cy, node.badge, 13 * s, bold=True, angle=90)
|
||||
_draw_centered(
|
||||
scene, node.label, box.x + strip, box.y, box.w - strip, box.h,
|
||||
15 * s, box.w - strip - 16 * s,
|
||||
)
|
||||
|
||||
|
||||
def _draw_arrow(scene: _Scene, pts: list[tuple[float, float]], lw: float, scale: float) -> None:
|
||||
if len(pts) < 2:
|
||||
return
|
||||
p1, p2 = pts[-2], pts[-1]
|
||||
dx, dy = p2[0] - p1[0], p2[1] - p1[1]
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
size = min(12 * scale, length * 0.55)
|
||||
ux, uy = dx / length, dy / length
|
||||
base = (p2[0] - ux * size, p2[1] - uy * size)
|
||||
shaft = list(pts[:-1]) + [base]
|
||||
scene.polyline(shaft, lw)
|
||||
wing = size * 0.42
|
||||
px, py = -uy * wing, ux * wing
|
||||
scene.polygon([p2, (base[0] + px, base[1] + py), (base[0] - px, base[1] - py)])
|
||||
|
||||
|
||||
def _point_along(pts: list[tuple[float, float]], frac: float) -> tuple[float, float, float, float]:
|
||||
segs = []
|
||||
total = 0.0
|
||||
for a, b in zip(pts, pts[1:]):
|
||||
length = math.hypot(b[0] - a[0], b[1] - a[1])
|
||||
segs.append((a, b, length))
|
||||
total += length
|
||||
if total <= 0:
|
||||
return pts[0][0], pts[0][1], 0.0, -1.0
|
||||
target = total * min(1.0, max(0.0, frac))
|
||||
acc = 0.0
|
||||
a, b = segs[-1][0], segs[-1][1]
|
||||
for a, b, length in segs:
|
||||
if acc + length >= target or length == 0:
|
||||
t = 0 if length == 0 else (target - acc) / length
|
||||
x = a[0] + (b[0] - a[0]) * t
|
||||
y = a[1] + (b[1] - a[1]) * t
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
norm = math.hypot(dx, dy) or 1.0
|
||||
px, py = -dy / norm, dx / norm
|
||||
if py > 0 or (abs(py) < 1e-6 and px < 0):
|
||||
px, py = -px, -py
|
||||
return x, y, px, py
|
||||
acc += length
|
||||
return a[0], a[1], 0.0, -1.0
|
||||
|
||||
|
||||
def _label_overlap(
|
||||
cx: float, cy: float, bw: float, bh: float,
|
||||
boxes: list[_Placed],
|
||||
busy: list[tuple[float, float, float, float]],
|
||||
pad: float,
|
||||
) -> float:
|
||||
ax, ay = cx - bw / 2 - pad, cy - bh / 2 - pad
|
||||
ar, ab = ax + bw + 2 * pad, ay + bh + 2 * pad
|
||||
total = 0.0
|
||||
for box in boxes:
|
||||
ix = max(0.0, min(ar, box.r) - max(ax, box.x))
|
||||
iy = max(0.0, min(ab, box.b) - max(ay, box.y))
|
||||
total += ix * iy * 4
|
||||
for x, y, w, h in busy:
|
||||
ix = max(0.0, min(ar, x + w) - max(ax, x))
|
||||
iy = max(0.0, min(ab, y + h) - max(ay, y))
|
||||
total += ix * iy
|
||||
return total
|
||||
|
||||
|
||||
def _clamp_label(
|
||||
cx: float, cy: float, bw: float, bh: float,
|
||||
canvas_w: float, canvas_h: float, pad: float,
|
||||
) -> tuple[float, float]:
|
||||
half_w, half_h = bw / 2 + pad, bh / 2 + pad
|
||||
cx = min(max(cx, half_w), max(half_w, canvas_w - half_w))
|
||||
cy = min(max(cy, half_h), max(half_h, canvas_h - half_h))
|
||||
return cx, cy
|
||||
|
||||
|
||||
def _draw_label(
|
||||
scene: _Scene,
|
||||
text: str,
|
||||
pts: list[tuple[float, float]],
|
||||
scale: float,
|
||||
boxes: list[_Placed],
|
||||
canvas_w: float,
|
||||
canvas_h: float,
|
||||
busy: list[tuple[float, float, float, float]],
|
||||
) -> None:
|
||||
label = (text or "").strip()
|
||||
if not label or len(pts) < 2:
|
||||
return
|
||||
s = scale
|
||||
size = 13 * s
|
||||
font = _load_font(int(round(size)), False)
|
||||
# Narrow wrap: the label stays in the column gap instead of covering a box.
|
||||
lines = _wrap(label, font, max(72, int(108 * s)))
|
||||
line_h = size * 1.2
|
||||
block_w = max(_text_wh(font, line)[0] for line in lines)
|
||||
block_h = line_h * len(lines)
|
||||
pad = 3 * s
|
||||
best: tuple[float, float, float] | None = None
|
||||
for frac in (0.5, 0.34, 0.66, 0.2, 0.8):
|
||||
x, y, px, py = _point_along(pts, frac)
|
||||
horizontal = abs(py) >= abs(px)
|
||||
shift = (5 * s + block_h / 2) if horizontal else (8 * s)
|
||||
for sign in (1.0, -1.0):
|
||||
cx, cy = _clamp_label(
|
||||
x + px * shift * sign, y + py * shift * sign,
|
||||
block_w, block_h, canvas_w, canvas_h, pad,
|
||||
)
|
||||
overlap = _label_overlap(cx, cy, block_w, block_h, boxes, busy, 1.0)
|
||||
rank = overlap + (0 if frac == 0.5 and sign > 0 else 0.01)
|
||||
if best is None or rank < best[0]:
|
||||
best = (rank, cx, cy)
|
||||
if overlap <= 0 and frac == 0.5 and sign > 0:
|
||||
best = (0.0, cx, cy)
|
||||
break
|
||||
if best is not None and best[0] <= 0:
|
||||
break
|
||||
if best is None:
|
||||
return
|
||||
cx, cy = best[1], best[2]
|
||||
scene.rect(
|
||||
cx - block_w / 2 - pad, cy - block_h / 2 - pad,
|
||||
block_w + 2 * pad, block_h + 2 * pad,
|
||||
width=0, fill="#ffffff", outline=None,
|
||||
)
|
||||
top = cy - block_h / 2
|
||||
busy.append((cx - block_w / 2 - pad, top - pad, block_w + 2 * pad, block_h + 2 * pad))
|
||||
for i, line in enumerate(lines):
|
||||
scene.text(cx, top + i * line_h + line_h / 2, line, size, anchor="mm")
|
||||
|
||||
|
||||
def _cell_text(
|
||||
scene: _Scene, x: float, y: float, w: float, h: float,
|
||||
caption: str, value: str, scale: float,
|
||||
) -> None:
|
||||
s = scale
|
||||
scene.text(x + 6 * s, y + 4 * s, caption, 9 * s, anchor="lt", fill="#444444")
|
||||
if not value:
|
||||
return
|
||||
font = _load_font(int(round(12 * s)), False)
|
||||
lines = _wrap(value, font, max(20, int(w - 12 * s)))[:2]
|
||||
line_h = 14 * s
|
||||
for i, line in enumerate(lines):
|
||||
scene.text(x + w / 2, y + 26 * s + i * line_h, line, 12 * s, anchor="mt")
|
||||
|
||||
|
||||
def _draw_frame(scene: _Scene, diagram: GsDiagram, frame: tuple[float, float, float, float], scale: float) -> None:
|
||||
s = scale
|
||||
x, y, w, h = frame
|
||||
lw = max(1.2, 1.3 * s)
|
||||
scene.rect(x, y, w, h, width=lw, fill=None)
|
||||
header_h = 76 * s
|
||||
footer_h = 56 * s
|
||||
scene.line(x, y + header_h, x + w, y + header_h, lw)
|
||||
scene.line(x, y + h - footer_h, x + w, y + h - footer_h, lw)
|
||||
|
||||
fracs = (0.14, 0.18, 0.14, 0.08, 0.28, 0.18)
|
||||
cells = (
|
||||
("USED AT", diagram.used_at),
|
||||
("AUTHOR", diagram.author),
|
||||
("DATE", diagram.date),
|
||||
("REV", diagram.rev),
|
||||
("PROJECT", diagram.project),
|
||||
("STATUS", diagram.status),
|
||||
)
|
||||
cursor = x
|
||||
for (caption, value), frac in zip(cells, fracs):
|
||||
cw = w * frac
|
||||
scene.line(cursor, y, cursor, y + header_h, lw)
|
||||
_cell_text(scene, cursor, y, cw, header_h, caption, value, s)
|
||||
cursor += cw
|
||||
|
||||
num_w = 130 * s
|
||||
scene.line(x + w - num_w, y + h - footer_h, x + w - num_w, y + h, lw)
|
||||
scene.text(x + 8 * s, y + h - footer_h + 4 * s, "NODE / TITLE", 9 * s, anchor="lt", fill="#444444")
|
||||
title = " ".join(part for part in (diagram.node, diagram.title) if part)
|
||||
if title:
|
||||
font = _load_font(int(round(13 * s)), True)
|
||||
lines = _wrap(title, font, max(40, int(w - num_w - 16 * s)))[:2]
|
||||
for i, line in enumerate(lines):
|
||||
scene.text(x + 8 * s, y + h - footer_h + 24 * s + i * 16 * s, line, 13 * s, bold=True, anchor="lt")
|
||||
scene.text(
|
||||
x + w - num_w / 2, y + h - footer_h + 8 * s, "NUMBER", 9 * s, anchor="mt", fill="#444444",
|
||||
)
|
||||
if diagram.number:
|
||||
scene.text(x + w - num_w / 2, y + h - footer_h / 2 + 6 * s, diagram.number, 14 * s, bold=True, anchor="mm")
|
||||
|
||||
|
||||
def render_dfd_gs(
|
||||
diagram: GsDiagram,
|
||||
*,
|
||||
scale: float = 2.0,
|
||||
want_svg: bool = True,
|
||||
) -> tuple[bytes, str | None]:
|
||||
"""Вернуть (png_bytes, svg или None)."""
|
||||
s = max(float(scale), 1.0)
|
||||
layout = _arrange(diagram, s)
|
||||
scene = _Scene(layout.width, layout.height)
|
||||
lw = max(1.2, 1.35 * s)
|
||||
if layout.frame is not None:
|
||||
_draw_frame(scene, diagram, layout.frame, s)
|
||||
for box in layout.placed.values():
|
||||
_draw_node(scene, box, s, lw)
|
||||
for route in layout.routes:
|
||||
_draw_arrow(scene, route.pts, lw, s)
|
||||
busy: list[tuple[float, float, float, float]] = []
|
||||
for route in layout.routes:
|
||||
_draw_label(
|
||||
scene, route.flow.label, route.pts, s,
|
||||
list(layout.placed.values()), layout.width, layout.height, busy,
|
||||
)
|
||||
svg = scene.to_svg() if want_svg else None
|
||||
return scene.to_png(), svg
|
||||
|
||||
|
||||
def write_dfd_gs_outputs(
|
||||
source: str,
|
||||
out_png: Path,
|
||||
out_svg: Path | None,
|
||||
*,
|
||||
scale: float = 2.0,
|
||||
) -> bool:
|
||||
"""Разобрать и нарисовать. DfdGsParseError при ошибке DSL."""
|
||||
diagram = parse_dfd_gs(source)
|
||||
png, svg = render_dfd_gs(diagram, scale=scale, want_svg=out_svg is not None)
|
||||
out_png.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_png.write_bytes(png)
|
||||
if out_svg is not None and svg is not None:
|
||||
out_svg.write_text(svg, encoding="utf-8")
|
||||
_log.info("DFD Гейна–Сарсона → %s", out_png.name)
|
||||
return True
|
||||
|
||||
|
||||
SAMPLE = """\
|
||||
title Зафиксировать решение приёмки
|
||||
node A44
|
||||
number 1
|
||||
author Тарасов И.А.
|
||||
project ИС ArtFreelance
|
||||
date 05.10.2026
|
||||
rev 01
|
||||
status WORKING
|
||||
used_at учебный проект
|
||||
|
||||
entity Artist Художник
|
||||
process 1 Принять файлы результата
|
||||
store D2 Файлы работ
|
||||
|
||||
Artist --> 1 файлы работы
|
||||
1 --> D2 превью и хеш
|
||||
"""
|
||||
@@ -37,12 +37,15 @@ DIAGRAM_LANGS = frozenset(
|
||||
"mmd",
|
||||
"idef0",
|
||||
"dfd",
|
||||
"dfd-gs",
|
||||
"gane-sarson",
|
||||
}
|
||||
)
|
||||
# Native Pillow renderers — never jar / Kroki.
|
||||
LOCAL_ONLY_TYPES = frozenset({"idef0", "dfd"})
|
||||
# Handled in this module, never as a Kroki diagram type.
|
||||
# idef0 and dfd-gs are Pillow; dfd draws itself (Graphviz, Kroki only inside dfd.py).
|
||||
LOCAL_ONLY_TYPES = frozenset({"idef0", "dfd", "dfd-gs"})
|
||||
# Local hi-res path uses a scale-specific cache key.
|
||||
SCALED_LOCAL_TYPES = frozenset({"mermaid", "idef0", "dfd"})
|
||||
SCALED_LOCAL_TYPES = frozenset({"mermaid", "idef0", "dfd", "dfd-gs"})
|
||||
FallbackMode = Literal["local", "remote", "off"]
|
||||
DiagramFormat = Literal["png", "svg"]
|
||||
|
||||
@@ -267,6 +270,7 @@ def diagram_engine_status(explicit_jar: str | None = None) -> str:
|
||||
except Exception as exc:
|
||||
lines = lines + f"\nMermaid: статус недоступен ({exc})"
|
||||
lines = lines + "\nIDEF0: локальный рендер (Pillow), оградка ```idef0"
|
||||
lines = lines + "\nDFD Гейна–Сарсона: локальный рендер (Pillow), оградка ```dfd-gs"
|
||||
try:
|
||||
from .dfd import dfd_engine_status
|
||||
|
||||
@@ -618,6 +622,10 @@ def render_diagram(
|
||||
from .dfd import CACHE_KEY_PREFIX as DFD_CACHE_PREFIX
|
||||
|
||||
cache_prefix = DFD_CACHE_PREFIX
|
||||
elif kroki_type == "dfd-gs":
|
||||
from .dfd_gs import CACHE_KEY_PREFIX as DFD_GS_CACHE_PREFIX
|
||||
|
||||
cache_prefix = DFD_GS_CACHE_PREFIX
|
||||
|
||||
# Local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
|
||||
local_prefix = cache_prefix
|
||||
@@ -722,6 +730,16 @@ def render_diagram(
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
raise RuntimeError("Не удалось отрендерить DFD-диаграмму")
|
||||
|
||||
if kroki_type == "dfd-gs":
|
||||
from .dfd_gs import DfdGsParseError, write_dfd_gs_outputs
|
||||
|
||||
try:
|
||||
if write_dfd_gs_outputs(prepared, out_png, svg_target, scale=scale):
|
||||
return _result(pscale=scale if scale > 1 else 1.0)
|
||||
except DfdGsParseError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
raise RuntimeError("Не удалось отрендерить DFD Гейна–Сарсона")
|
||||
|
||||
raise RuntimeError(f"Неизвестный локальный тип диаграммы: {kroki_type}")
|
||||
|
||||
# 1b) Mermaid local: system browser → Playwright Chromium → QuickJS
|
||||
|
||||
+99
-55
@@ -35,6 +35,10 @@ NATIVE_DIAGRAM_LANGS = {
|
||||
"data-flow-diagram": "dfd",
|
||||
"yourdon": "dfd",
|
||||
"uml-yourdon": "dfd",
|
||||
"dfd-gs": "dfd-gs",
|
||||
"uml-dfd-gs": "dfd-gs",
|
||||
"gane-sarson": "dfd-gs",
|
||||
"uml-gane-sarson": "dfd-gs",
|
||||
}
|
||||
|
||||
|
||||
@@ -110,67 +114,48 @@ def is_app_owned_scheme(scheme: DiagramScheme) -> bool:
|
||||
return scheme.author.strip().lower() == APP_SCHEME_AUTHOR
|
||||
|
||||
|
||||
def _schemes_payload(schemes: dict[str, DiagramScheme]) -> dict[str, Any]:
|
||||
return {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
|
||||
|
||||
|
||||
def migrate_user_schemes(base: Path | None = None) -> SchemesMigrationResult:
|
||||
"""
|
||||
Sync app-owned schemes in the user file with the bundled template.
|
||||
Add bundled schemes that are missing from the user file.
|
||||
|
||||
- Bundled ids: always present; overwrite user entries with author=md2gost.
|
||||
- User overrides of bundled ids (empty/other author): kept as-is.
|
||||
- Orphaned author=md2gost ids no longer in the template: removed.
|
||||
- Other custom schemes: kept.
|
||||
Existing entries are kept as written: edits of built-in ids, custom
|
||||
schemes, and schemes with author=md2gost are not overwritten or removed.
|
||||
A file that cannot be parsed is left untouched.
|
||||
"""
|
||||
result = SchemesMigrationResult()
|
||||
path = user_schemes_path(base)
|
||||
if not path.is_file():
|
||||
return result
|
||||
|
||||
bundled = _load_schemes_file(bundled_schemes_path())
|
||||
user = _load_schemes_file(path)
|
||||
out: dict[str, DiagramScheme] = {}
|
||||
|
||||
for sid, scheme in user.items():
|
||||
if sid in bundled:
|
||||
if is_app_owned_scheme(scheme):
|
||||
continue # step below writes bundled
|
||||
out[sid] = scheme
|
||||
continue
|
||||
if is_app_owned_scheme(scheme):
|
||||
result.removed.append(sid)
|
||||
_log.info("Миграция схем: удалена устаревшая встроенная «%s»", sid)
|
||||
continue
|
||||
out[sid] = scheme
|
||||
|
||||
for sid, scheme in bundled.items():
|
||||
if sid in out:
|
||||
continue # user-owned override
|
||||
prev = user.get(sid)
|
||||
if prev is None:
|
||||
result.added.append(sid)
|
||||
_log.info("Миграция схем: добавлена «%s»", sid)
|
||||
elif prev.to_dict() != scheme.to_dict():
|
||||
result.updated.append(sid)
|
||||
_log.info("Миграция схем: обновлена «%s»", sid)
|
||||
out[sid] = scheme
|
||||
|
||||
if _schemes_payload(out) == _schemes_payload(user):
|
||||
result.added.clear()
|
||||
result.updated.clear()
|
||||
result.removed.clear()
|
||||
raw, err = _read_json_object(path)
|
||||
if err or raw is None:
|
||||
_log.warning("Миграция схем пропущена, файл не изменён (%s): %s", path, err)
|
||||
return result
|
||||
|
||||
save_schemes(out, path)
|
||||
bundled = _load_schemes_file(bundled_schemes_path())
|
||||
existing = {str(key).lower().strip() for key in raw}
|
||||
for sid, scheme in bundled.items():
|
||||
if sid in existing:
|
||||
continue
|
||||
raw[sid] = scheme.to_dict()
|
||||
result.added.append(sid)
|
||||
_log.info("Миграция схем: добавлена «%s»", sid)
|
||||
|
||||
if not result.added:
|
||||
return result
|
||||
|
||||
path.write_text(
|
||||
json.dumps(raw, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result.written = True
|
||||
return result
|
||||
|
||||
|
||||
def ensure_user_schemes(base: Path | None = None) -> Path:
|
||||
"""
|
||||
Ensure md2gost.schemes.json exists next to the app, then sync app-owned
|
||||
schemes from the bundled template (add / update / remove orphans).
|
||||
Ensure md2gost.schemes.json exists next to the app, then add any
|
||||
bundled schemes that are not already in the user file.
|
||||
"""
|
||||
dest = user_schemes_path(base)
|
||||
if not dest.is_file():
|
||||
@@ -186,16 +171,21 @@ def ensure_user_schemes(base: Path | None = None) -> Path:
|
||||
return dest
|
||||
|
||||
|
||||
def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]:
|
||||
def _read_json_object(path: Path) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""Return (object, None). Missing file is ({}, None). Unreadable is (None, error)."""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
return {}, None
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_log.warning("Не удалось прочитать схемы %s: %s", path, exc)
|
||||
return {}
|
||||
# utf-8-sig: Notepad on Windows often writes a BOM, which json.loads rejects.
|
||||
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
return None, str(exc)
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return None, "корень JSON должен быть объектом"
|
||||
return raw, None
|
||||
|
||||
|
||||
def _schemes_from_raw(raw: dict[str, Any]) -> dict[str, DiagramScheme]:
|
||||
out: dict[str, DiagramScheme] = {}
|
||||
for key, val in raw.items():
|
||||
sid = str(key).lower().strip()
|
||||
@@ -208,16 +198,70 @@ def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]:
|
||||
return out
|
||||
|
||||
|
||||
def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]:
|
||||
raw, err = _read_json_object(path)
|
||||
if err or raw is None:
|
||||
if path.is_file():
|
||||
_log.warning("Не удалось прочитать схемы %s: %s", path, err)
|
||||
return {}
|
||||
return _schemes_from_raw(raw)
|
||||
|
||||
|
||||
def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]:
|
||||
return _load_schemes_file(path)
|
||||
|
||||
|
||||
def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path:
|
||||
def load_schemes_report(path: Path) -> tuple[dict[str, DiagramScheme], str | None]:
|
||||
"""Load schemes. Error string is set when the file exists but cannot be parsed."""
|
||||
raw, err = _read_json_object(path)
|
||||
if err or raw is None:
|
||||
if path.is_file():
|
||||
_log.warning("Не удалось прочитать схемы %s: %s", path, err)
|
||||
return {}, err
|
||||
return _schemes_from_raw(raw), None
|
||||
|
||||
|
||||
def save_schemes(
|
||||
schemes: dict[str, DiagramScheme],
|
||||
path: Path | None = None,
|
||||
*,
|
||||
removed_ids: set[str] | None = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Write schemes, keeping JSON entries the loader does not understand
|
||||
(unknown ids, extra fields). ``removed_ids`` are normalized ids the user
|
||||
deleted or renamed away.
|
||||
"""
|
||||
dest = path or user_schemes_path()
|
||||
payload = _schemes_payload(schemes)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
raw, err = _read_json_object(dest)
|
||||
if err or raw is None:
|
||||
raw = {}
|
||||
removed = {sid.lower() for sid in (removed_ids or set())}
|
||||
norm_to_key: dict[str, str] = {}
|
||||
for key in list(raw):
|
||||
sid = str(key).lower().strip()
|
||||
if sid in removed:
|
||||
del raw[key]
|
||||
continue
|
||||
norm_to_key.setdefault(sid, str(key))
|
||||
|
||||
for sid in sorted(schemes):
|
||||
scheme = schemes[sid]
|
||||
data = scheme.to_dict()
|
||||
key = norm_to_key.get(sid)
|
||||
if key is None:
|
||||
raw[sid] = data
|
||||
continue
|
||||
base = raw.get(key)
|
||||
merged = dict(base) if isinstance(base, dict) else {}
|
||||
merged.update(data)
|
||||
if not scheme.theme:
|
||||
merged.pop("theme", None)
|
||||
raw[key] = merged
|
||||
|
||||
dest.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
json.dumps(raw, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return dest
|
||||
@@ -230,7 +274,7 @@ def claim_user_scheme_if_diverged(
|
||||
) -> DiagramScheme:
|
||||
"""
|
||||
If scheme id is in the app template and content differs while still
|
||||
author=md2gost, clear author so migration will not overwrite user edits.
|
||||
author=md2gost, clear author so the copy is marked as the user's.
|
||||
"""
|
||||
stock = (bundled if bundled is not None else _load_schemes_file(bundled_schemes_path())).get(
|
||||
scheme.id
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"docs": "LAYOUT_TOP_DOWN() / LAYOUT_LEFT_RIGHT()\nMotivation_Stakeholder/Driver/Assessment/Goal/Outcome/Principle/Requirement/Constraint/Meaning/Value\nStrategy_Resource/Capability/ValueStream/CourseOfAction\nBusiness_Actor/Role/Collaboration/Interface/Process/Function/Interaction/Event/Service/Object/Contract/Representation/Product\nApplication_Component/Collaboration/Interface/Function/Interaction/Process/Event/Service/DataObject\nTechnology_Node/Device/SystemSoftware/Collaboration/Interface/Path/CommunicationNetwork/Function/Process/Interaction/Event/Service/Artifact\nPhysical_Equipment/Facility/DistributionNetwork/Material\nImplementation_WorkPackage/Deliverable/Event/Plateau/Gap\nGrouping / Other_Grouping / Group / Other_Location / Junction_And / Junction_Or\nRel_Composition/Aggregation/Assignment/Realization/Serving/Access(_r|_w|_rw)/Influence/Triggering/Flow/Specialization/Association(_dir)\nRel_Type_Up/Down/Left/Right(from, to, \"label?\")\nLay_U/D/L/R — скрытое выравнивание",
|
||||
"ai-prompt": "Рисуй ArchiMate 3.2 (Open Group C226). В блоке ```uml-archimate (или ```archimate) только макросы Archimate-PlantUML. Без @startuml и без !include — схема добавит сама.\n\nПравила: один рисунок = один viewpoint. Поведение по слоям (Business_Process ≠ Application_Process ≠ Technology_Process). Цепочка: Active —Assignment→ Process/Function —Realization→ Service —Serving→ потребитель; Access к пассивному объекту. Serving от поставщика к потребителю; Realization от конкретного к абстрактному; Composition/Aggregation целое→часть. Не Association «на всё». Не UML/C4/BPMN. Alias латиница (не end/start/group). 8–18 элементов. Сначала элементы, потом Rel_*. Подписи по-русски.\n\nМакросы: Motivation_*, Strategy_*, Business_*, Application_*, Technology_*, Physical_*, Implementation_*, Grouping, Junction_And/Or. Связи: Rel_Composition/Aggregation/Assignment/Realization/Serving/Access_r|w|rw/Influence/Triggering/Flow/Specialization/Association + _Up/_Down/_Left/_Right. Макет: LAYOUT_TOP_DOWN() или LAYOUT_LEFT_RIGHT(). Полный гайд: docs/archimate.md.",
|
||||
"includes": [],
|
||||
"prefix": "@startuml\n!include <archimate/Archimate>\n!theme archimate-standard from <archimate/themes>\n",
|
||||
"prefix": "@startuml\n!include <archimate/Archimate>\n",
|
||||
"postfix": "\n@enduml"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,4 @@ class Caption(BlockElement):
|
||||
def parse(cls, source: Source) -> Match[str] | None:
|
||||
m = source.match
|
||||
source.consume()
|
||||
return m
|
||||
return m
|
||||
@@ -5,11 +5,12 @@ from marko.ext.gfm.elements import TableCell as GfmTableCell
|
||||
|
||||
|
||||
MERGE_V_MARKERS = {"^", "^^"}
|
||||
MERGE_H_MARKERS = {">", ">>"}
|
||||
# `<` is the same colspan-continue as `>`, and is allowed in the header row.
|
||||
MERGE_H_MARKERS = {">", ">>", "<", "<<"}
|
||||
|
||||
|
||||
class TableCell(GfmTableCell):
|
||||
"""GFM table cell with rowspan/colspan continue markers (^ / >)."""
|
||||
"""GFM table cell with rowspan/colspan continue markers (^ / > / <)."""
|
||||
|
||||
def __init__(self, text: str, position: int | None = None) -> None:
|
||||
stripped = text.strip()
|
||||
|
||||
+262
-42
@@ -16,7 +16,7 @@ from .diagram_schemes import (
|
||||
claim_user_scheme_if_diverged,
|
||||
ensure_user_schemes,
|
||||
get_schemes,
|
||||
load_schemes_from_path,
|
||||
load_schemes_report,
|
||||
reload_schemes,
|
||||
save_schemes,
|
||||
user_schemes_path,
|
||||
@@ -29,6 +29,7 @@ from .help_content import (
|
||||
load_docs_catalog,
|
||||
load_prompt_catalog,
|
||||
)
|
||||
from .office import DEFAULT_OFFICE_ENGINE, OFFICE_ENGINE_MODES
|
||||
from .pipeline import ConvertRequest, convert, default_output_path, timestamped_output_path
|
||||
from .profiles import (
|
||||
DEFAULT_HEADING_NUMBERING,
|
||||
@@ -64,6 +65,14 @@ TABLE_LABELS = {
|
||||
"legacy": "Резать + «Продолжение»",
|
||||
"caption": "Резать + разрыв страницы + «Продолжение»",
|
||||
}
|
||||
OFFICE_ENGINE_LABELS = {
|
||||
"auto": "Авто: Word → LibreOffice",
|
||||
"word": "Только Microsoft Word",
|
||||
"libre": "LibreOffice Writer (UNO)",
|
||||
}
|
||||
GUI_OFFICE_ENGINE_MODES = tuple(
|
||||
key for key in OFFICE_ENGINE_MODES if key != "openoffice"
|
||||
)
|
||||
FALLBACK_LABELS = {
|
||||
"remote": "Авто: локально, иначе интернет (kroki.io)",
|
||||
"local": "Только этот компьютер (без kroki.io)",
|
||||
@@ -180,6 +189,65 @@ def _tip(widget, text: str) -> None:
|
||||
_Tooltip(widget, text)
|
||||
|
||||
|
||||
def import_output_path(docx_path: str) -> str:
|
||||
"""Markdown path written by DOCX → MD for this document."""
|
||||
return os.path.splitext(os.path.abspath(docx_path))[0] + ".md"
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
|
||||
|
||||
|
||||
def output_tracks_source(source: str, current: str) -> bool:
|
||||
"""True when the output field is still the automatic path for this source.
|
||||
|
||||
Markdown sources track ``name.docx``. DOCX sources (import) track ``name.md``.
|
||||
``default_output_path`` always appends ``.docx``, so it must not be used to
|
||||
decide whether an import path is still automatic.
|
||||
"""
|
||||
if not source:
|
||||
return False
|
||||
current = (current or "").strip()
|
||||
if not current:
|
||||
return True
|
||||
if source.lower().endswith(".docx"):
|
||||
auto = import_output_path(source)
|
||||
else:
|
||||
auto = default_output_path(source)
|
||||
return _same_path(current, auto)
|
||||
|
||||
|
||||
def next_markdown_output(source_md: str, current: str, auto: bool) -> tuple[str, bool]:
|
||||
"""DOCX output after the user picks a markdown source.
|
||||
|
||||
A non-``.docx`` path is leftover from DOCX → MD and is not a chosen
|
||||
conversion target, even if the auto-output flag was cleared.
|
||||
"""
|
||||
current = (current or "").strip()
|
||||
if auto or not current.lower().endswith(".docx"):
|
||||
return default_output_path(source_md), True
|
||||
return current, auto
|
||||
|
||||
|
||||
def next_import_output(docx_path: str, current: str, auto: bool) -> tuple[str, bool]:
|
||||
"""Markdown output after the user picks a DOCX to import.
|
||||
|
||||
A ``.docx`` path is the previous MD → DOCX target and must not be reused.
|
||||
"""
|
||||
current = (current or "").strip()
|
||||
if auto or not current.lower().endswith(".md"):
|
||||
return import_output_path(docx_path), True
|
||||
return current, auto
|
||||
|
||||
|
||||
def coerce_docx_output(source_md: str, current: str) -> str:
|
||||
"""Output path for an MD → DOCX run. Non-docx paths fall back to the default."""
|
||||
current = (current or "").strip()
|
||||
if current.lower().endswith(".docx"):
|
||||
return current
|
||||
return default_output_path(source_md) if source_md else current
|
||||
|
||||
|
||||
class Md2GostApp:
|
||||
def __init__(self, initial: ConvertRequest | None = None):
|
||||
self.req = initial or ConvertRequest()
|
||||
@@ -195,7 +263,9 @@ class Md2GostApp:
|
||||
self._last_md = ""
|
||||
self._schemes: dict[str, DiagramScheme] = {}
|
||||
self._scheme_ids: list[str] = []
|
||||
self._scheme_file_keys: set[str] = set()
|
||||
self._scheme_selecting = False
|
||||
self._schemes_unreadable: str | None = None
|
||||
self._debug_next = False
|
||||
|
||||
self._win_files: tk.Toplevel | None = None
|
||||
@@ -245,6 +315,7 @@ class Md2GostApp:
|
||||
self.toc_var = tk.StringVar()
|
||||
self.table_var = tk.StringVar()
|
||||
self.listing_var = tk.StringVar()
|
||||
self.office_engine_var = tk.StringVar()
|
||||
self.emdash_var = tk.BooleanVar(value=False)
|
||||
self.hr_pagebreak_var = tk.BooleanVar(value=False)
|
||||
self.auto_title_var = tk.BooleanVar(value=False)
|
||||
@@ -1067,10 +1138,18 @@ class Md2GostApp:
|
||||
tip="off — Word сам переносит; word — Split + «Продолжение» после сборки (нужен Word)")
|
||||
self._combo(main, 4, "Продолжение листингов", self.listing_var, _combo_values(TABLE_LABELS, LISTING_CONTINUATION_MODES),
|
||||
tip="То же для листингов; word требует Windows + Word + pywin32")
|
||||
self._combo(
|
||||
main,
|
||||
5,
|
||||
"Движок пост-разрыва",
|
||||
self.office_engine_var,
|
||||
_combo_values(OFFICE_ENGINE_LABELS, GUI_OFFICE_ENGINE_MODES),
|
||||
tip="При режиме word: Word COM или LibreOffice UNO (soffice)",
|
||||
)
|
||||
|
||||
ttk.Label(main, text="Смещение страниц").grid(row=5, column=0, sticky="w", padx=(0, 8), pady=3)
|
||||
ttk.Label(main, text="Смещение страниц").grid(row=6, column=0, sticky="w", padx=(0, 8), pady=3)
|
||||
page_row = ttk.Frame(main)
|
||||
page_row.grid(row=5, column=1, sticky="w", pady=3)
|
||||
page_row.grid(row=6, column=1, sticky="w", pady=3)
|
||||
page_entry = ttk.Entry(page_row, textvariable=self.page_start_var, width=8)
|
||||
page_entry.pack(side=tk.LEFT)
|
||||
_tip(
|
||||
@@ -1085,7 +1164,7 @@ class Md2GostApp:
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
flags = ttk.Frame(main)
|
||||
flags.grid(row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||
flags.grid(row=7, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||
checks = [
|
||||
("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"),
|
||||
("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"),
|
||||
@@ -1139,17 +1218,41 @@ class Md2GostApp:
|
||||
|
||||
log_frame = ttk.LabelFrame(outer, text="Журнал", padding=4)
|
||||
log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 0))
|
||||
log_toolbar = ttk.Frame(log_frame)
|
||||
log_toolbar.pack(fill=tk.X, pady=(0, 4))
|
||||
ttk.Button(
|
||||
log_toolbar, text="Копировать выделение", command=self._log_copy_selection
|
||||
).pack(side=tk.LEFT)
|
||||
ttk.Button(
|
||||
log_toolbar, text="Копировать весь журнал", command=self._log_copy_all
|
||||
).pack(side=tk.LEFT, padx=(6, 0))
|
||||
log_body = ttk.Frame(log_frame)
|
||||
log_body.pack(fill=tk.BOTH, expand=True)
|
||||
self.log = tk.Text(
|
||||
log_frame, height=7, wrap=tk.WORD, relief=tk.FLAT,
|
||||
log_body, height=7, wrap=tk.WORD, relief=tk.FLAT,
|
||||
bg="#fffdf8", fg=INK, padx=8, pady=6,
|
||||
font=("Consolas", 9) if os.name == "nt" else ("monospace", 9),
|
||||
exportselection=True, undo=False,
|
||||
)
|
||||
scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
|
||||
scroll = ttk.Scrollbar(log_body, command=self.log.yview)
|
||||
self.log.configure(yscrollcommand=scroll.set)
|
||||
self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.log.insert("1.0", "Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n")
|
||||
self.log.configure(state=tk.DISABLED)
|
||||
self.log.insert(
|
||||
"1.0",
|
||||
"Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n",
|
||||
)
|
||||
self._log_menu = tk.Menu(self.root, tearoff=0)
|
||||
self._log_menu.add_command(
|
||||
label="Копировать", command=self._log_copy_selection
|
||||
)
|
||||
self._log_menu.add_command(
|
||||
label="Копировать весь журнал", command=self._log_copy_all
|
||||
)
|
||||
self.log.bind("<Button-3>", self._log_context_menu)
|
||||
self.log.bind("<Control-Button-1>", self._log_context_menu)
|
||||
self.log.bind("<Key>", self._log_keypress)
|
||||
self.log.bind("<<Copy>>", self._log_copy_event)
|
||||
|
||||
def _build_schemes_tab(self, parent) -> None:
|
||||
ttk.Label(
|
||||
@@ -1235,12 +1338,23 @@ class Md2GostApp:
|
||||
if not hasattr(self, "_scheme_list"):
|
||||
return
|
||||
path = ensure_user_schemes()
|
||||
self._schemes = load_schemes_from_path(path)
|
||||
self._schemes, err = load_schemes_report(path)
|
||||
self._schemes_unreadable = err
|
||||
self._scheme_file_keys = set() if err else set(self._schemes)
|
||||
self._scheme_ids = sorted(self._schemes.keys())
|
||||
self._scheme_list.delete(0, tk.END)
|
||||
for sid in self._scheme_ids:
|
||||
title = self._schemes[sid].title or sid
|
||||
self._scheme_list.insert(tk.END, title)
|
||||
if err:
|
||||
self._clear_scheme_form()
|
||||
self._scheme_fence_hint.configure(
|
||||
text=f"Файл не разобран и не изменён: {err}"
|
||||
)
|
||||
self._append_log(
|
||||
f"Файл схем не прочитан, он не перезаписан: {path} ({err})"
|
||||
)
|
||||
return
|
||||
if not self._scheme_ids:
|
||||
self._clear_scheme_form()
|
||||
return
|
||||
@@ -1251,6 +1365,22 @@ class Md2GostApp:
|
||||
self._scheme_list.see(idx)
|
||||
self._fill_scheme_form(self._schemes[target])
|
||||
|
||||
def _persist_schemes(self, select_id: str | None = None) -> str | None:
|
||||
"""Write self._schemes to disk. Refuses to overwrite a file that did not parse."""
|
||||
if self._schemes_unreadable:
|
||||
messagebox.showerror(
|
||||
"md2gost",
|
||||
"Файл схем не разобран, поэтому он не перезаписан.\n"
|
||||
f"{self._schemes_unreadable}",
|
||||
)
|
||||
return None
|
||||
path = user_schemes_path()
|
||||
removed = set(self._scheme_file_keys) - set(self._schemes)
|
||||
save_schemes(self._schemes, path, removed_ids=removed)
|
||||
reload_schemes()
|
||||
self._reload_schemes_ui(select_id=select_id)
|
||||
return str(path)
|
||||
|
||||
def _clear_scheme_form(self) -> None:
|
||||
self._scheme_selecting = True
|
||||
self._sch_id.set("")
|
||||
@@ -1295,6 +1425,9 @@ class Md2GostApp:
|
||||
return None
|
||||
includes_raw = self._text_get(self._sch_includes)
|
||||
includes = [ln.strip() for ln in includes_raw.splitlines() if ln.strip()]
|
||||
theme = ""
|
||||
if old_id and old_id in self._schemes:
|
||||
theme = self._schemes[old_id].theme
|
||||
return DiagramScheme(
|
||||
id=sid,
|
||||
title=self._sch_title.get().strip() or sid,
|
||||
@@ -1305,6 +1438,7 @@ class Md2GostApp:
|
||||
includes=includes,
|
||||
prefix=self._text_get(self._sch_prefix),
|
||||
postfix=self._text_get(self._sch_postfix),
|
||||
theme=theme,
|
||||
)
|
||||
|
||||
def _scheme_commit_current(self) -> str | None:
|
||||
@@ -1321,6 +1455,15 @@ class Md2GostApp:
|
||||
return scheme.id
|
||||
|
||||
def _scheme_add(self) -> None:
|
||||
if self._schemes_unreadable:
|
||||
messagebox.showerror(
|
||||
"md2gost",
|
||||
"Файл схем не разобран, новая схема не записана.\n"
|
||||
f"{self._schemes_unreadable}",
|
||||
)
|
||||
return
|
||||
if self._scheme_ids and self._scheme_commit_current() is None:
|
||||
return
|
||||
n = 1
|
||||
while f"custom{n}" in self._schemes:
|
||||
n += 1
|
||||
@@ -1336,13 +1479,24 @@ class Md2GostApp:
|
||||
prefix="@startuml\n",
|
||||
postfix="\n@enduml",
|
||||
)
|
||||
self._reload_schemes_ui(select_id=sid)
|
||||
path = self._persist_schemes(select_id=sid)
|
||||
if path:
|
||||
self._append_log(f"Схема добавлена: {sid} ({path})")
|
||||
|
||||
def _scheme_dup(self) -> None:
|
||||
sel = self._scheme_list.curselection()
|
||||
if not sel:
|
||||
if not self._scheme_list.curselection():
|
||||
return
|
||||
src = self._schemes[self._scheme_ids[sel[0]]]
|
||||
if self._schemes_unreadable:
|
||||
messagebox.showerror(
|
||||
"md2gost",
|
||||
"Файл схем не разобран, копия не записана.\n"
|
||||
f"{self._schemes_unreadable}",
|
||||
)
|
||||
return
|
||||
src_id = self._scheme_commit_current()
|
||||
if src_id is None:
|
||||
return
|
||||
src = self._schemes[src_id]
|
||||
n = 1
|
||||
while f"{src.id}_copy{n}" in self._schemes:
|
||||
n += 1
|
||||
@@ -1351,7 +1505,7 @@ class Md2GostApp:
|
||||
id=sid,
|
||||
title=src.title,
|
||||
version=src.version,
|
||||
author=src.author,
|
||||
author="",
|
||||
docs=src.docs,
|
||||
ai_prompt=src.ai_prompt,
|
||||
includes=list(src.includes),
|
||||
@@ -1359,7 +1513,9 @@ class Md2GostApp:
|
||||
postfix=src.postfix,
|
||||
theme=src.theme,
|
||||
)
|
||||
self._reload_schemes_ui(select_id=sid)
|
||||
path = self._persist_schemes(select_id=sid)
|
||||
if path:
|
||||
self._append_log(f"Схема скопирована: {sid} ({path})")
|
||||
|
||||
def _scheme_del(self) -> None:
|
||||
sel = self._scheme_list.curselection()
|
||||
@@ -1369,20 +1525,17 @@ class Md2GostApp:
|
||||
if not messagebox.askyesno("md2gost", f"Удалить схему «{sid}» из файла пользователя?"):
|
||||
return
|
||||
del self._schemes[sid]
|
||||
path = user_schemes_path()
|
||||
save_schemes(self._schemes, path)
|
||||
reload_schemes()
|
||||
self._reload_schemes_ui()
|
||||
self._append_log(f"Схема удалена: {sid}")
|
||||
path = self._persist_schemes()
|
||||
if path:
|
||||
self._append_log(f"Схема удалена: {sid}")
|
||||
|
||||
def _scheme_save_all(self) -> None:
|
||||
sid = self._scheme_commit_current()
|
||||
if sid is None and self._scheme_ids:
|
||||
return
|
||||
path = user_schemes_path()
|
||||
save_schemes(self._schemes, path)
|
||||
reload_schemes()
|
||||
self._reload_schemes_ui(select_id=sid)
|
||||
path = self._persist_schemes(select_id=sid)
|
||||
if not path:
|
||||
return
|
||||
self._append_log(f"Схемы сохранены: {path}")
|
||||
messagebox.showinfo("md2gost", f"Сохранено:\n{path}")
|
||||
|
||||
@@ -1651,18 +1804,21 @@ class Md2GostApp:
|
||||
def _on_output_edited(self, *_args) -> None:
|
||||
if not self._last_md:
|
||||
return
|
||||
current = self.output_var.get().strip()
|
||||
auto = default_output_path(self._last_md)
|
||||
self._auto_output = (not current) or os.path.normcase(os.path.abspath(current)) == os.path.normcase(auto)
|
||||
self._auto_output = output_tracks_source(self._last_md, self.output_var.get())
|
||||
|
||||
def _apply_output(self, path: str, auto: bool) -> None:
|
||||
if path != self.output_var.get().strip():
|
||||
self.output_var.set(path)
|
||||
self._auto_output = auto
|
||||
|
||||
def _set_markdown(self, path: str, from_user_output: bool = False) -> None:
|
||||
path = os.path.abspath(path)
|
||||
self._last_md = path
|
||||
self.zone_label.configure(text=os.path.basename(path))
|
||||
self.zone_path.configure(text=path)
|
||||
if not from_user_output and self._auto_output:
|
||||
self.output_var.set(default_output_path(path))
|
||||
self._auto_output = True
|
||||
if not from_user_output:
|
||||
output, auto = next_markdown_output(path, self.output_var.get(), self._auto_output)
|
||||
self._apply_output(output, auto)
|
||||
self._append_log(f"Файл: {path}")
|
||||
|
||||
def _set_docx(self, path: str) -> None:
|
||||
@@ -1670,10 +1826,8 @@ class Md2GostApp:
|
||||
self._last_md = path
|
||||
self.zone_label.configure(text=os.path.basename(path))
|
||||
self.zone_path.configure(text=path)
|
||||
if self._auto_output:
|
||||
stem = os.path.splitext(path)[0]
|
||||
self.output_var.set(stem + ".md")
|
||||
self._auto_output = True
|
||||
output, auto = next_import_output(path, self.output_var.get(), self._auto_output)
|
||||
self._apply_output(output, auto)
|
||||
self._append_log(f"DOCX для импорта: {path}")
|
||||
|
||||
def _import_docx(self) -> None:
|
||||
@@ -1697,6 +1851,10 @@ class Md2GostApp:
|
||||
self.toc_var.set(_display(TOC_LABELS, req.toc_mode or DEFAULT_TOC_MODE))
|
||||
self.table_var.set(_display(TABLE_LABELS, req.table_continuation or DEFAULT_TABLE_CONTINUATION))
|
||||
self.listing_var.set(_display(TABLE_LABELS, req.listing_continuation or DEFAULT_LISTING_CONTINUATION))
|
||||
office_engine = req.office_engine or DEFAULT_OFFICE_ENGINE
|
||||
if office_engine not in GUI_OFFICE_ENGINE_MODES:
|
||||
office_engine = DEFAULT_OFFICE_ENGINE
|
||||
self.office_engine_var.set(_display(OFFICE_ENGINE_LABELS, office_engine))
|
||||
self.fallback_var.set(_display(FALLBACK_LABELS, req.diagram_fallback or "remote"))
|
||||
self.format_var.set(_display(FORMAT_LABELS, req.diagram_format or "png"))
|
||||
scale = getattr(req, "diagram_scale", 2.0) or 2.0
|
||||
@@ -1737,6 +1895,11 @@ class Md2GostApp:
|
||||
toc_mode=_combo_key(self.toc_var.get(), TOC_MODES, DEFAULT_TOC_MODE),
|
||||
table_continuation=_combo_key(self.table_var.get(), TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION),
|
||||
listing_continuation=_combo_key(self.listing_var.get(), LISTING_CONTINUATION_MODES, DEFAULT_LISTING_CONTINUATION),
|
||||
office_engine=_combo_key(
|
||||
self.office_engine_var.get(),
|
||||
GUI_OFFICE_ENGINE_MODES,
|
||||
DEFAULT_OFFICE_ENGINE,
|
||||
),
|
||||
emdash_to_hyphen=self.emdash_var.get(),
|
||||
hr_pagebreak=self.hr_pagebreak_var.get(),
|
||||
auto_title=self.auto_title_var.get(),
|
||||
@@ -1796,10 +1959,10 @@ class Md2GostApp:
|
||||
return
|
||||
if is_docx:
|
||||
target = (req.output or "").strip()
|
||||
if target.lower().endswith(".docx"):
|
||||
target = os.path.splitext(req.filename)[0] + ".md"
|
||||
if not target.lower().endswith(".md"):
|
||||
target = import_output_path(req.filename)
|
||||
req.output = target
|
||||
self.output_var.set(target)
|
||||
self._apply_output(target, True)
|
||||
if target and os.path.isfile(target):
|
||||
if not messagebox.askyesno(
|
||||
"md2gost", f"Файл уже есть:\n{target}\n\nПерезаписать?"
|
||||
@@ -1811,9 +1974,12 @@ class Md2GostApp:
|
||||
self.output_var.set(target)
|
||||
self._append_log("Импорт DOCX → Markdown…")
|
||||
elif not req.check_only:
|
||||
target = (req.output or "").strip() or (
|
||||
default_output_path(req.filename) if req.filename else ""
|
||||
)
|
||||
raw = (req.output or "").strip()
|
||||
target = coerce_docx_output(req.filename, raw)
|
||||
if raw and not raw.lower().endswith(".docx"):
|
||||
self._append_log(f"После импорта выход был не .docx, для сборки беру {target}")
|
||||
req.output = target
|
||||
self._apply_output(target, True)
|
||||
if target and os.path.isfile(target):
|
||||
if not messagebox.askyesno(
|
||||
"md2gost", f"Файл уже есть:\n{target}\n\nПерезаписать?"
|
||||
@@ -1860,17 +2026,71 @@ class Md2GostApp:
|
||||
self._append_log(result.message or "Ошибка")
|
||||
messagebox.showerror("md2gost", result.message or "Ошибка конвертации")
|
||||
|
||||
def _log_to_clipboard(self, text: str) -> None:
|
||||
self.root.clipboard_clear()
|
||||
self.root.clipboard_append(text)
|
||||
self.root.update_idletasks()
|
||||
|
||||
def _log_copy_selection(self) -> None:
|
||||
try:
|
||||
if not self.log.tag_ranges(tk.SEL):
|
||||
return
|
||||
text = self.log.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||||
except tk.TclError:
|
||||
return
|
||||
if text:
|
||||
self._log_to_clipboard(text)
|
||||
|
||||
def _log_copy_all(self) -> None:
|
||||
text = self.log.get("1.0", tk.END).strip()
|
||||
if text:
|
||||
self._log_to_clipboard(text)
|
||||
|
||||
def _log_context_menu(self, event) -> str:
|
||||
try:
|
||||
self._log_menu.tk_popup(event.x_root, event.y_root)
|
||||
finally:
|
||||
self._log_menu.grab_release()
|
||||
return "break"
|
||||
|
||||
def _log_copy_event(self, _event=None) -> str | None:
|
||||
self._log_copy_selection()
|
||||
return "break"
|
||||
|
||||
def _log_keypress(self, event) -> str | None:
|
||||
"""Read-only log: allow selection/copy, block edits."""
|
||||
if event.state & 0x4:
|
||||
key = event.keysym.lower()
|
||||
if key == "c":
|
||||
self._log_copy_selection()
|
||||
return "break"
|
||||
if key == "a":
|
||||
self.log.tag_add(tk.SEL, "1.0", tk.END)
|
||||
self.log.mark_set(tk.INSERT, tk.END)
|
||||
self.log.see(tk.INSERT)
|
||||
return "break"
|
||||
if key == "insert":
|
||||
self._log_copy_selection()
|
||||
return "break"
|
||||
if event.keysym in (
|
||||
"Left", "Right", "Up", "Down", "Home", "End",
|
||||
"Prior", "Next", "Shift_L", "Shift_R", "Control_L", "Control_R",
|
||||
"Tab", "ISO_Left_Tab",
|
||||
):
|
||||
return None
|
||||
if not event.char:
|
||||
return None
|
||||
return "break"
|
||||
|
||||
def _append_log(self, text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
if not hasattr(self, "log"):
|
||||
return
|
||||
self.log.configure(state=tk.NORMAL)
|
||||
if not text.endswith("\n"):
|
||||
text += "\n"
|
||||
self.log.insert(tk.END, text)
|
||||
self.log.see(tk.END)
|
||||
self.log.configure(state=tk.DISABLED)
|
||||
|
||||
def run(self) -> None:
|
||||
self.root.mainloop()
|
||||
|
||||
+23
-8
@@ -87,8 +87,9 @@ CLI (тот же движок)
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
Ссылка: @Таблица:tbl1
|
||||
Склеивание: ^ — ячейка сверху (rowspan), > — ячейка слева (colspan).
|
||||
Не ставить ^/> в шапке; > — не в первом столбце.
|
||||
Склеивание: ^ — ячейка сверху (rowspan), > или < — ячейка слева (colspan).
|
||||
< можно в шапке, чтобы заголовок занял всю строку. ^ и > в шапке нельзя;
|
||||
< и > — не в первом столбце.
|
||||
|
||||
Листинг
|
||||
%code1 Название
|
||||
@@ -140,9 +141,21 @@ CLI (тот же движок)
|
||||
System --> Client статус
|
||||
```
|
||||
|
||||
DFD Гейна–Сарсона (локальный Pillow, без Graphviz):
|
||||
%dfd_gs Приёмка
|
||||
```dfd-gs
|
||||
title Зафиксировать решение приёмки
|
||||
node A44
|
||||
entity Artist Художник
|
||||
process 1 Принять файлы результата
|
||||
store D2 Файлы работ
|
||||
Artist --> 1 файлы работы
|
||||
1 --> D2 превью и хеш
|
||||
```
|
||||
|
||||
+listing — ещё и листинг с исходником.
|
||||
+landscape — отдельная альбомная страница (A4, повёрт на 90°) вокруг рисунка/таблицы, затем снова книжная.
|
||||
Языки: uml, plantuml, mermaid / mmd, idef0, dfd / data-flow-diagram, или uml-<id> / <id> из файла схем (c4, usecase, archimate, …).
|
||||
Языки: uml, plantuml, mermaid / mmd, idef0, dfd / data-flow-diagram, dfd-gs / gane-sarson, или uml-<id> / <id> из файла схем (c4, usecase, archimate, …).
|
||||
Формат в Word: PNG по умолчанию (PlantUML, локальный Mermaid, IDEF0 и DFD ~2× для чёткости); --diagram-format svg — вектор + PNG-запасной (Word 2016+).
|
||||
Подробнее — Справка → Схемы и раздел ниже в инструкции.
|
||||
|
||||
@@ -192,11 +205,11 @@ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
|
||||
Первый запуск
|
||||
Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m)
|
||||
создаётся md2gost.schemes.json из встроенного шаблона.
|
||||
При каждом запуске схемы с author=md2gost синхронизируются с шаблоном приложения
|
||||
(добавление новых id, обновление, удаление исчезнувших вроде bpmn).
|
||||
Схемы с пустым или другим author не трогаются.
|
||||
При каждом запуске в файл добавляются только те встроенные схемы, которых ещё нет.
|
||||
Уже записанные схемы не перезаписываются и не удаляются: правки и свои шаблоны
|
||||
сохраняются. Файл с ошибкой JSON или с BOM не затирается.
|
||||
Если правите встроенную схему в GUI и сохраняете — author сбрасывается,
|
||||
чтобы следующая миграция не затёрла ваши правки.
|
||||
схема помечается как ваша.
|
||||
|
||||
Оградка в markdown
|
||||
```uml — обычный PlantUML (или ```plantuml)
|
||||
@@ -205,8 +218,10 @@ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
|
||||
```uml-archimate — ArchiMate 3.2 (макросы Business_*, Rel_Serving, …)
|
||||
```mermaid / ```mmd — Mermaid локально (браузер / Chromium / QuickJS) или через Kroki
|
||||
```idef0 — IDEF0 (FIPS 183): боксы, стрелки ICOM, рамка NIST; локальный Pillow
|
||||
```dfd / ```data-flow-diagram — DFD (pbauermeister/dfd): process/entity/store, --> ;
|
||||
```dfd / ```data-flow-diagram — DFD Йордона (pbauermeister/dfd): process/entity/store, --> ;
|
||||
Graphviz (вшитый / «Скачать Graphviz» / PATH) или Kroki
|
||||
```dfd-gs / ```gane-sarson — DFD Гейна–Сарсона: те же узлы, скруглённый процесс,
|
||||
хранилище с полосой D1, штамп Ramus; локальный Pillow, без Graphviz
|
||||
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase.
|
||||
|
||||
Пример
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Office-suite backends for post-save table/listing continuation splits."""
|
||||
|
||||
from .detect import (
|
||||
DEFAULT_OFFICE_ENGINE,
|
||||
OFFICE_ENGINE_MODES,
|
||||
OfficeInstall,
|
||||
detect_office_install,
|
||||
resolve_office_engine,
|
||||
)
|
||||
from .dispatch import fix_continuations_with_engine
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_OFFICE_ENGINE",
|
||||
"OFFICE_ENGINE_MODES",
|
||||
"OfficeInstall",
|
||||
"detect_office_install",
|
||||
"resolve_office_engine",
|
||||
"fix_continuations_with_engine",
|
||||
]
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Detect installed Word / LibreOffice / OpenOffice for post-save table splits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .uno_cli import soffice_cli_path
|
||||
|
||||
OFFICE_ENGINE_MODES = ("auto", "word", "libre", "openoffice")
|
||||
DEFAULT_OFFICE_ENGINE = "auto"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OfficeInstall:
|
||||
engine: str # "word" | "libre" | "openoffice"
|
||||
label: str
|
||||
soffice_path: str | None = None
|
||||
python_path: str | None = None
|
||||
|
||||
|
||||
# First successful ``auto`` resolution is reused for the process lifetime so a
|
||||
# second convert does not re-probe Word and fall through to LibreOffice.
|
||||
_cached_auto_resolve: tuple[str, OfficeInstall] | None = None
|
||||
|
||||
|
||||
def _program_dirs(kind: str) -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
for env in ("ProgramFiles", "ProgramFiles(x86)"):
|
||||
base = os.environ.get(env)
|
||||
if base:
|
||||
roots.append(Path(base))
|
||||
if sys.platform != "win32":
|
||||
roots.extend(
|
||||
[
|
||||
Path("/usr/lib/libreoffice"),
|
||||
Path("/usr/lib64/libreoffice"),
|
||||
Path("/opt/libreoffice"),
|
||||
Path("/opt/openoffice4"),
|
||||
Path("/Applications/LibreOffice.app/Contents"),
|
||||
]
|
||||
)
|
||||
candidates: list[Path] = []
|
||||
if kind == "libre":
|
||||
for root in roots:
|
||||
candidates.extend(
|
||||
[
|
||||
root / "LibreOffice" / "program",
|
||||
root / "libreoffice" / "program",
|
||||
root / "program",
|
||||
root / "MacOS",
|
||||
]
|
||||
)
|
||||
else:
|
||||
for root in roots:
|
||||
candidates.extend(
|
||||
[
|
||||
root / "OpenOffice 4" / "program",
|
||||
root / "openoffice4" / "program",
|
||||
]
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _soffice_names() -> tuple[str, ...]:
|
||||
if sys.platform == "win32":
|
||||
# soffice.com = CLI/headless; soffice.exe opens the Start Center GUI.
|
||||
return ("soffice.com", "soffice.exe")
|
||||
return ("soffice", "soffice.bin")
|
||||
|
||||
|
||||
def _python_names() -> tuple[str, ...]:
|
||||
if sys.platform == "win32":
|
||||
return ("python.exe",)
|
||||
return ("python", "python3")
|
||||
|
||||
|
||||
def _find_soffice_install(kind: str) -> OfficeInstall | None:
|
||||
label = "LibreOffice" if kind == "libre" else "OpenOffice"
|
||||
seen: set[str] = set()
|
||||
|
||||
for directory in _program_dirs(kind):
|
||||
for soffice_name in _soffice_names():
|
||||
soffice = directory / soffice_name
|
||||
key = str(soffice.resolve()) if soffice.exists() else ""
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
python_path = None
|
||||
for py_name in _python_names():
|
||||
candidate = directory / py_name
|
||||
if candidate.is_file():
|
||||
python_path = str(candidate)
|
||||
break
|
||||
if python_path is None:
|
||||
continue
|
||||
return OfficeInstall(
|
||||
engine=kind,
|
||||
label=label,
|
||||
soffice_path=soffice_cli_path(str(soffice)),
|
||||
python_path=python_path,
|
||||
)
|
||||
|
||||
which = shutil.which("soffice") or shutil.which("soffice.bin")
|
||||
if which:
|
||||
program_dir = Path(which).resolve().parent
|
||||
python_path = None
|
||||
for py_name in _python_names():
|
||||
candidate = program_dir / py_name
|
||||
if candidate.is_file():
|
||||
python_path = str(candidate)
|
||||
break
|
||||
if python_path:
|
||||
lower = str(program_dir).lower()
|
||||
detected = "openoffice" if "openoffice" in lower else "libre"
|
||||
if kind != detected:
|
||||
return None
|
||||
return OfficeInstall(
|
||||
engine=kind,
|
||||
label=label,
|
||||
soffice_path=soffice_cli_path(str(Path(which).resolve())),
|
||||
python_path=python_path,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def detect_office_install(engine: str) -> OfficeInstall | None:
|
||||
"""Return install info for a concrete engine id."""
|
||||
if engine == "word":
|
||||
return _detect_word()
|
||||
if engine == "libre":
|
||||
return _find_soffice_install("libre")
|
||||
if engine == "openoffice":
|
||||
return _find_soffice_install("openoffice")
|
||||
return None
|
||||
|
||||
|
||||
def _word_com_available() -> bool:
|
||||
"""True when pywin32 is importable and Word.Application is registered."""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import win32com.client # type: ignore # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Word.Application"):
|
||||
pass
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _detect_word() -> OfficeInstall | None:
|
||||
if not _word_com_available():
|
||||
return None
|
||||
return OfficeInstall(engine="word", label="Microsoft Word")
|
||||
|
||||
|
||||
def _install_still_available(install: OfficeInstall) -> bool:
|
||||
if install.engine == "word":
|
||||
return _word_com_available()
|
||||
if install.soffice_path and install.python_path:
|
||||
return Path(install.soffice_path).is_file() and Path(install.python_path).is_file()
|
||||
return False
|
||||
|
||||
|
||||
def clear_office_engine_cache() -> None:
|
||||
"""Drop cached ``auto`` resolution (tests / long-running hosts)."""
|
||||
global _cached_auto_resolve
|
||||
_cached_auto_resolve = None
|
||||
|
||||
|
||||
def resolve_office_engine(mode: str) -> tuple[str | None, OfficeInstall | None]:
|
||||
"""
|
||||
Resolve ``mode`` to a concrete engine id and optional install record.
|
||||
|
||||
``auto`` tries Word, then LibreOffice, then OpenOffice. The first successful
|
||||
``auto`` pick is cached for subsequent calls in the same process.
|
||||
"""
|
||||
global _cached_auto_resolve
|
||||
|
||||
if mode not in OFFICE_ENGINE_MODES:
|
||||
mode = DEFAULT_OFFICE_ENGINE
|
||||
|
||||
if mode == "auto":
|
||||
if _cached_auto_resolve is not None:
|
||||
cached_engine, cached_install = _cached_auto_resolve
|
||||
if _install_still_available(cached_install):
|
||||
return _cached_auto_resolve
|
||||
_cached_auto_resolve = None
|
||||
|
||||
for candidate in ("word", "libre", "openoffice"):
|
||||
install = detect_office_install(candidate)
|
||||
if install is not None:
|
||||
_cached_auto_resolve = (candidate, install)
|
||||
return candidate, install
|
||||
_cached_auto_resolve = None
|
||||
return None, None
|
||||
|
||||
install = detect_office_install(mode)
|
||||
if install is None:
|
||||
return None, None
|
||||
return mode, install
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user