v0.6.0 BigUpdate
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

This commit is contained in:
Igor20264
2026-10-20 11:38:06 +03:00
parent daa84f8bce
commit 8223e0ac14
77 changed files with 12158 additions and 117 deletions
@@ -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.
+33 -2
View File
@@ -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 — качество растра).
+2 -1
View File
@@ -135,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` — вывести исходник схемы отдельным Листингом.
+250
View File
@@ -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.
+442
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

+7 -4
View File
@@ -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` даёт ложные срабатывания; это эвристика.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.wordcraft/
out/
dist/
*.tsbuildinfo
+32
View File
@@ -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()],
},
})
+4333
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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"
}
}
+75
View File
@@ -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()
})
+60
View File
@@ -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 }
}
+21
View File
@@ -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)
+12
View File
@@ -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>
+31
View File
@@ -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>
})
}
+1
View File
@@ -0,0 +1 @@
declare module '*.css' {}
+370
View File
@@ -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.
![Архитектура](figures/context.svg "%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 `![Описание](figures/context.svg "%fig${n} Название рисунка")\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'} />
}
+126
View File
@@ -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
}
+11
View File
@@ -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 {}
+59
View File
@@ -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
}
+38
View File
@@ -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.',
'Кнопки над редактором вставляют эти заготовки в текущую строку.',
],
},
{
id: 'diagrams',
title: 'Схемы',
paragraphs: [
'Исходник схемы хранится в .puml или в ограде ```plantuml прямо в отчёте. Перед оградой — %id Название, тогда схема станет рисунком.',
'Языки ограды: plantuml, uml, mermaid, idef0, dfd. Картинку из схемы соберёт ядро. Пока в окне виден исходник, а файлы .svg открываются картинкой.',
'Готовые рисунки лежат в figures. На них можно сослаться из отчёта обычной картинкой Markdown.',
],
},
]
+22
View File
@@ -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>,
)
+151
View File
@@ -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
}
+239
View File
@@ -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'
}
+211
View File
@@ -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
}
+176
View File
@@ -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
}
+252
View File
@@ -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%;
}
+44
View File
@@ -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, [])
}
+8
View File
@@ -0,0 +1,8 @@
/** Кнопки рамки окна. К конвейеру md2gost не относятся. */
export interface ChromeApi {
/** darwin — кнопки слева, остальные системы — справа. */
platform: string
minimize(): Promise<void>
toggleMaximize(): Promise<void>
close(): Promise<void>
}
+15
View File
@@ -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>
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+19
View File
@@ -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"
]
}
+19
View File
@@ -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"]
}
+3 -2
View File
@@ -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` — альбомная страница под широкий рисунок/таблицу
+23 -1
View File
@@ -33,7 +33,8 @@ 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 |
## Встроенные схемы
@@ -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
+21 -6
View File
@@ -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,
))
+961
View File
@@ -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 превью и хеш
"""
+21 -3
View File
@@ -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
+4
View File
@@ -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",
}
+1 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -45,4 +45,4 @@ class Caption(BlockElement):
def parse(cls, source: Source) -> Match[str] | None:
m = source.match
source.consume()
return m
return m
+3 -2
View File
@@ -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()
+79 -16
View File
@@ -189,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()
@@ -1693,18 +1752,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:
@@ -1712,10 +1774,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:
@@ -1847,10 +1907,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Перезаписать?"
@@ -1862,9 +1922,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Перезаписать?"
+19 -4
View File
@@ -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+).
Подробнее — Справка → Схемы и раздел ниже в инструкции.
@@ -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.
Пример
+48 -15
View File
@@ -22,6 +22,11 @@ class OfficeInstall:
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)"):
@@ -133,41 +138,69 @@ def detect_office_install(engine: str) -> OfficeInstall | None:
return None
def _detect_word() -> OfficeInstall | None:
def _word_com_available() -> bool:
"""True when pywin32 is importable and Word.Application is registered."""
if sys.platform != "win32":
return None
return False
try:
import win32com.client # type: ignore
import win32com.client # type: ignore # noqa: F401
except ImportError:
return None
word = None
return False
try:
word = win32com.client.DispatchEx("Word.Application")
return OfficeInstall(engine="word", label="Microsoft Word")
except Exception:
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
finally:
if word is not None:
try:
word.Quit()
except Exception:
pass
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.
``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)
+10 -4
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from ..word_fix import FixResult, fix_continuations
from .detect import DEFAULT_OFFICE_ENGINE, resolve_office_engine
from .detect import DEFAULT_OFFICE_ENGINE, OfficeInstall, resolve_office_engine
from .uno_fix import fix_continuations_uno
@@ -11,6 +11,7 @@ def fix_continuations_with_engine(
path: str,
*,
engine: str = DEFAULT_OFFICE_ENGINE,
install: OfficeInstall | None = None,
tables: bool = True,
listings: bool = True,
repeat_header: bool = False,
@@ -18,10 +19,15 @@ def fix_continuations_with_engine(
"""
Split cross-page tables/listings using the selected office engine.
``engine`` is ``auto|word|libre|openoffice``. On missing backend returns
``ok=False`` with a reason; conversion should continue.
``engine`` is ``auto|word|libre|openoffice``. Pass a concrete ``engine`` plus
``install`` from ``resolve_office_engine`` to skip a second resolve. On
missing backend returns ``ok=False`` with a reason; conversion should
continue.
"""
resolved, install = resolve_office_engine(engine)
if install is not None and engine in ("word", "libre", "openoffice"):
resolved = engine
else:
resolved, install = resolve_office_engine(engine)
if resolved is None or install is None:
if engine == "auto":
return FixResult(
+10 -7
View File
@@ -34,16 +34,19 @@ def _open_in_word(path: str) -> bool:
import win32com.client # type: ignore
except ImportError:
return False
word = None
from ..word_fix import word_com_apartment
try:
word = win32com.client.DispatchEx("Word.Application")
word.Visible = True
word.Documents.Open(path)
return True
with word_com_apartment():
word = win32com.client.DispatchEx("Word.Application")
try:
word.Visible = True
word.Documents.Open(path)
return True
finally:
word = None
except Exception:
return False
finally:
pass # keep Word open for the user
def _open_in_soffice(soffice_path: str, path: str) -> bool:
+10 -3
View File
@@ -37,13 +37,20 @@ def _office_program_dir(install: OfficeInstall) -> Path | None:
def _uno_worker_env(worker_dir: Path, install: OfficeInstall) -> tuple[dict[str, str], Path]:
"""Build env + cwd so bundled ``program/python.exe`` can ``import uno``."""
env = os.environ.copy()
# System Python's PYTHONHOME/PYTHONPATH confuse LO's embedded interpreter
# ("Could not find platform independent libraries <prefix>").
for key in (
"PYTHONHOME",
"PYTHONPATH",
"PYTHONUSERBASE",
"VIRTUAL_ENV",
"CONDA_PREFIX",
):
env.pop(key, None)
program_dir = _office_program_dir(install)
path_parts = [str(worker_dir)]
if program_dir is not None:
path_parts.append(str(program_dir))
existing = env.get("PYTHONPATH", "")
if existing:
path_parts.append(existing)
env["PYTHONPATH"] = os.pathsep.join(path_parts)
env.setdefault("PYTHONIOENCODING", "utf-8")
if install.soffice_path:
+125
View File
@@ -39,6 +39,28 @@ DOCX_SAVE_FILTER_SPECS = {
}
MAX_PASSES = 20
# Stop if pagination never settles (each pass is one split, budget can grow).
PASS_SAFETY_CAP = 200
def continuation_pass_limit(object_count, splits_done=0):
"""10 + (tables + listings) + 1 per split already made.
One pass performs one split.
"""
try:
count = int(object_count)
except (TypeError, ValueError):
count = 0
try:
done = int(splits_done)
except (TypeError, ValueError):
done = 0
if count < 0:
count = 0
if done < 0:
done = 0
return 10 + count + done
# UNO enum numeric values (``from com.sun.star.*`` is unavailable in some LO/OO Pythons).
BREAK_NONE = 0
@@ -162,6 +184,109 @@ def find_page_break_row(page_numbers):
return None
def resume_page_before_cut(cut_page):
"""Page to scan from after a split: the page before the cut, not page 1.
Rows that move to the continuation start on ``cut_page``. Only that page
and the pages after it can shift, so the next pass starts at ``cut_page - 1``.
"""
try:
page = int(cut_page)
except (TypeError, ValueError):
return 1
if page <= 2:
return 1
return page - 1
def locate_break_row(n_rows, page_at, min_page=1):
"""Return ``(break_row_1based, cut_page)`` or ``(None, None)``.
``page_at(row_1based)`` returns a page number or None. Pages are treated as
non-decreasing. The search skips a table that ends before ``min_page`` and
binary-searches the first later jump, instead of reading every row from
the start. A failed probe falls back to a forward scan.
"""
try:
n_rows = int(n_rows)
except (TypeError, ValueError):
return None, None
try:
min_page = int(min_page)
except (TypeError, ValueError):
min_page = 1
if n_rows < 2:
return None, None
if min_page < 1:
min_page = 1
cache = {}
def probe(row):
if row in cache:
return cache[row]
try:
page = page_at(row)
except Exception:
page = None
if page is not None:
try:
page = int(page)
except (TypeError, ValueError):
page = None
cache[row] = page
return page
first = probe(1)
last = probe(n_rows)
if first is None or last is None:
return _linear_break_row(n_rows, probe, min_page)
if last < min_page or first == last:
return None, None
threshold = (first + 1) if first >= min_page else min_page
row = _first_row_at_or_after(n_rows, probe, threshold)
if row is None or row <= 1:
return _linear_break_row(n_rows, probe, min_page)
cut = probe(row)
prev = probe(row - 1)
if cut is None or prev is None or prev >= cut or cut < threshold:
return _linear_break_row(n_rows, probe, min_page)
return row, cut
def _first_row_at_or_after(n_rows, probe, threshold):
lo = 1
hi = n_rows
found = None
while lo <= hi:
mid = (lo + hi) // 2
page = probe(mid)
if page is None:
return None
if page >= threshold:
found = mid
hi = mid - 1
else:
lo = mid + 1
return found
def _linear_break_row(n_rows, probe, min_page):
prev = probe(1)
if prev is None:
return None, None
row = 2
while row <= n_rows:
page = probe(row)
if page is None:
page = prev
if page > prev and page >= min_page:
return row, page
prev = page
row += 1
return None, None
def is_orphan_header_break(break_at, had_header):
return bool(had_header and break_at == 2)
+85 -13
View File
@@ -11,7 +11,8 @@ import sys
import time
from uno_helpers import (
MAX_PASSES,
PASS_SAFETY_CAP,
continuation_pass_limit,
CaptionInfo,
as_unicode,
caption_style_name,
@@ -22,6 +23,8 @@ from uno_helpers import (
BREAK_PAGE_BEFORE,
CONTROL_PARAGRAPH_BREAK,
find_page_break_row,
locate_break_row,
resume_page_before_cut,
is_caption_orphaned,
is_orphan_header_break,
parse_caption_text,
@@ -489,6 +492,20 @@ def _caption_for_split(doc, table):
)
def _count_split_targets(doc, tables, listings):
"""Captioned tables and listings this pass is allowed to split."""
found = 0
for table in _body_tables(doc):
caption = _caption_before_table(doc, table)
if caption is None:
continue
if caption.kind == "table" and tables:
found += 1
elif caption.kind == "listing" and listings:
found += 1
return found
def _caption_page_before_table(doc, table):
try:
anchor = _uno_call(table, "Anchor")
@@ -506,6 +523,26 @@ def _caption_page_before_table(doc, table):
return None
def _page_at_row(doc, table, row_1based):
"""Page number of one row, or None. Rows are 1-based, UNO cells are 0-based."""
row = int(row_1based) - 1
if row < 0:
return None
try:
ncols = int(table.getColumns().getCount())
except Exception:
ncols = 1
for col in range(max(1, ncols)):
try:
cell = table.getCellByPosition(col, row)
page = _page_at_range(doc, _uno_call(cell, "Start"))
if page is not None:
return int(page)
except Exception:
continue
return None
def _row_page_numbers(doc, table):
pages = []
try:
@@ -1049,10 +1086,16 @@ def _copy_header_to_continuation(first_table, cont_table):
pass
def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details):
def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details, start_page=1):
"""Split the first crossing table. Returns (made, cut_page)."""
made = 0
cut_page = None
try:
start_page = int(start_page)
except (TypeError, ValueError):
start_page = 1
body_tables = _body_tables(doc)
for table in reversed(body_tables):
for table in body_tables:
caption = _caption_for_split(doc, table)
if caption is None:
continue
@@ -1061,9 +1104,18 @@ def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details):
if caption.kind == "listing" and not listings:
continue
pages = _row_page_numbers(doc, table)
if caption.kind == "table" and pages and not caption.is_continuation:
if is_caption_orphaned(_caption_page_before_table(doc, table), pages[0]):
try:
row_count = int(table.getRows().getCount())
except Exception:
continue
if start_page > 1 and row_count > 0:
last_page = _page_at_row(doc, table, row_count)
if last_page is not None and last_page < start_page:
continue
first_page = _page_at_row(doc, table, 1) if row_count else None
if caption.kind == "table" and first_page is not None and not caption.is_continuation:
if is_caption_orphaned(_caption_page_before_table(doc, table), first_page):
try:
if _page_break_before_table_caption(doc, table):
details.append(
@@ -1082,10 +1134,14 @@ def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details):
details.append("skip: orphan-caption page break: {0}".format(exc))
continue
break_at = find_page_break_row(pages)
break_at, cut_page = locate_break_row(
row_count,
lambda row, current=table: _page_at_row(doc, current, row),
start_page,
)
if break_at is None:
continue
if break_at > int(table.getRows().getCount()):
if break_at > row_count:
continue
had_header = _table_has_header_row(table)
@@ -1169,9 +1225,9 @@ def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details):
details.append(
"split: {0} {1} @row {2}".format(caption.kind, caption.number, break_at)
)
return made
return made, cut_page
return made
return made, cut_page
def run_worker(port, path, engine, tables, listings, repeat_header):
@@ -1187,14 +1243,30 @@ def run_worker(port, path, engine, tables, listings, repeat_header):
doc, uno, ctx, tables, listings, details
)
_repair_split_table_borders(doc, tables, listings, details)
for _ in range(MAX_PASSES):
targets = _count_split_targets(doc, tables, listings)
passes_done = 0
start_page = 1
while passes_done < continuation_pass_limit(targets, splits):
if passes_done >= PASS_SAFETY_CAP:
details.append("skip: pass safety cap")
break
_ensure_pagination(uno, ctx, doc)
made = _fix_pass(
uno, ctx, doc, tables, listings, repeat_header, details
made, cut_page = _fix_pass(
uno, ctx, doc, tables, listings, repeat_header, details, start_page
)
passes_done += 1
if made == 0:
if start_page > 1:
start_page = 1
details.append("recheck: from page 1")
continue
break
splits += made
if cut_page:
start_page = resume_page_before_cut(cut_page)
details.append(
"resume: page {0} after cut at {1}".format(start_page, cut_page)
)
doc = _reload_document(uno, ctx, doc, path, engine)
_ensure_pagination(uno, ctx, doc)
repaired += _repair_missing_continuations(
+11 -1
View File
@@ -95,10 +95,20 @@ def check_docx_page_fill(path: str) -> tuple[list[PageFillIssue], str]:
"(pip install pywin32) и Microsoft Word."
)
from .word_fix import word_com_apartment
try:
with word_com_apartment():
return _check_docx_page_fill_in_word(path, win32com.client)
except Exception as exc:
return [], f"Проверка вёрстки пропущена: не удалось открыть Word ({exc})."
def _check_docx_page_fill_in_word(path: str, win32com_client):
word = None
doc = None
try:
word = win32com.client.DispatchEx("Word.Application")
word = win32com_client.DispatchEx("Word.Application")
word.Visible = False
word.DisplayAlerts = 0
abs_path = str(path)
+2 -1
View File
@@ -436,7 +436,8 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
split_engine, split_install = resolve_office_engine(req.office_engine)
fix = fix_continuations_with_engine(
abs_out,
engine=req.office_engine,
engine=split_engine or req.office_engine,
install=split_install,
tables=(req.table_continuation == "word"),
listings=(req.listing_continuation == "word"),
repeat_header=bool(req.table_repeat_header),
+10 -3
View File
@@ -142,9 +142,13 @@ class Image(Renderable, RequiresNumbering):
need = Length(int(height) + int(caption_reserve))
remaining = layout_state.remaining_page_height
if remaining < need:
if self.landscape:
# Already on a fresh landscape section — shrink instead of soft page-break
# (soft break + section break = empty landscape page before the figure).
at_top_of_landscape = (
self.landscape and layout_state.current_page_height == 0
)
if at_top_of_landscape:
# Too tall for a whole landscape page. Shrink here: a soft break
# on this fresh section used to stack with the section break and
# leave an empty page before the figure.
avail = Length(max(0, int(remaining) - int(caption_reserve)))
if avail > 0 and self._image.height > avail:
ratio = int(avail) / int(self._image.height)
@@ -152,6 +156,9 @@ class Image(Renderable, RequiresNumbering):
self._image.width = Length(int(self._image.width * ratio))
height = self._image.height
else:
# Does not fit the leftover of the current page (including a
# second landscape figure after the first). Keep the fitted size
# and let Word move it; do not squash it into the gap.
height = Length(int(height) + int(remaining))
yield (rendered_image := RenderedInfo(self._docx_paragraph, Length(height)))
+52 -9
View File
@@ -52,32 +52,75 @@ class List(Renderable):
self._paragraphs.append(paragraph)
return paragraph
@staticmethod
def _is_hyphen_run(run) -> bool:
"""True for a run that carries only w:noBreakHyphen (text is empty)."""
if run.text:
return False
return bool(run._element.xpath("./w:noBreakHyphen"))
@classmethod
def _logical_text(cls, runs) -> str:
parts = []
for run in runs:
if cls._is_hyphen_run(run):
parts.append("-")
else:
parts.append(run.text or "")
return "".join(parts)
def finalize_punctuation(self) -> None:
"""Apply TZ list punctuation: bullet → lowercase + ';' (last '.'); numbered → Capital + '.'."""
"""Apply TZ list punctuation: bullet → lowercase + ';' (last '.'); numbered → Capital + '.'.
Case and the final mark are written into existing runs. Runs are not merged:
merging drops w:noBreakHyphen and inline code formatting, so «REST-интерфейс»
became «RESTинтерфейс».
"""
for i, paragraph in enumerate(self._paragraphs):
runs = paragraph._docx_paragraph.runs
if len(runs) < 2:
continue
# Text after marker run
text_runs = runs[1:]
full = "".join(r.text or "" for r in text_runs).strip()
logical = self._logical_text(text_runs)
full = logical.strip()
if not full:
continue
is_last = i == len(self._paragraphs) - 1
if self._ordered:
fixed = full[0].upper() + full[1:] if full else full
fixed = full[0].upper() + full[1:]
if not fixed.endswith("."):
fixed = fixed.rstrip(";,. ") + "."
else:
fixed = full[0].lower() + full[1:] if full else full
fixed = full[0].lower() + full[1:]
if is_last:
fixed = fixed.rstrip(";,. ") + "."
else:
fixed = fixed.rstrip(";,. ") + ";"
# Put all text into first content run, clear others
text_runs[0].text = fixed
for r in text_runs[1:]:
r.text = ""
if logical == fixed:
continue
self._apply_list_punctuation(text_runs, full, fixed)
@classmethod
def _apply_list_punctuation(cls, text_runs, full: str, fixed: str) -> None:
if full[0] != fixed[0]:
for run in text_runs:
if cls._is_hyphen_run(run) or not run.text:
continue
run.text = fixed[0] + run.text[1:]
break
desired_end = fixed[-1]
last = None
for run in text_runs:
if cls._is_hyphen_run(run) or not run.text:
continue
last = run
if last is None:
return
trimmed = last.text.rstrip(";,. ")
if trimmed.endswith(desired_end):
last.text = trimmed
else:
last.text = trimmed + desired_end
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
RenderedInfo | Renderable, None, None]:
+34
View File
@@ -56,6 +56,10 @@ class Renderer:
self._after_toc = False
self._body_section_started = False
self._landscape_depth = 0
# Exit from landscape is delayed so the next +landscape table/figure
# stays in the same section. Closing and reopening here is two
# next-page breaks with nothing between them: a blank portrait page.
self._portrait_return_pending = False
max_height, max_width = content_size(landscape=False)
self._layout_tracker = LayoutTracker(max_height, max_width)
@@ -87,6 +91,11 @@ class Renderer:
apply_centered_page_footer(new_section)
def _enter_landscape(self) -> None:
if self._portrait_return_pending:
# Previous block already left us in the landscape section.
self._portrait_return_pending = False
self._landscape_depth += 1
return
if self._landscape_depth > 0:
self._landscape_depth += 1
return
@@ -109,12 +118,34 @@ class Renderer:
self._landscape_depth -= 1
if self._landscape_depth > 0:
return
self._portrait_return_pending = True
def _commit_portrait_return(self) -> None:
"""Close the landscape section once the next block is portrait content."""
if not self._portrait_return_pending:
return
self._portrait_return_pending = False
section = self._document.add_section(WD_SECTION.NEW_PAGE)
apply_section_geometry(section, landscape=False)
apply_centered_page_footer(section)
max_height, max_width = content_size(landscape=False)
self._layout_tracker.set_page_size(max_height, max_width)
@staticmethod
def _hosts_only_landscape_figures(renderable: Renderable) -> bool:
"""Paragraph whose only content is +landscape figures.
The paragraph itself is not marked landscape; the figure opens the
section. Returning to portrait before that figure inserts a blank page
between two photos in a row.
"""
images = getattr(renderable, "_images", None)
if not images or not all(getattr(img, "landscape", False) for img in images):
return False
paragraph = getattr(renderable, "_docx_paragraph", None)
text = (paragraph.text or "").strip() if paragraph is not None else ""
return text == ""
def process(self, renderables: list[Renderable]):
items = self._ensure_appendix_index(list(renderables))
for i in range(len(items)):
@@ -124,6 +155,7 @@ class Renderer:
self.render(items[i])
self._flush_to_new_screen()
self._commit_portrait_return()
# If document had no TOC, still add page numbers to the only section
if not self._body_section_started:
section = self._document.sections[0]
@@ -316,6 +348,8 @@ class Renderer:
if wants_landscape:
self._enter_landscape()
elif not self._hosts_only_landscape_figures(renderable):
self._commit_portrait_return()
try:
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
+157 -17
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import os
import re
import sys
from contextlib import contextmanager
from dataclasses import dataclass, field
# wdActiveEndPageNumber
@@ -22,7 +23,28 @@ _CAPTION_RE = re.compile(
)
_CONTINUATION_RE = re.compile(r"^Продолжение\s+(?:Таблицы|Листинга)\b", re.IGNORECASE)
MAX_PASSES = 5
# Stop if Word keeps reporting a page break after every single split.
PASS_SAFETY_CAP = 200
@contextmanager
def word_com_apartment():
"""Initialize COM on this thread and release that initialization afterwards.
Each GUI conversion runs on a new thread. Importing ``win32com`` initializes
COM only on the first thread that imports it, so the next conversion raises
``CO_E_NOTINITIALIZED`` (-2147221008) at ``DispatchEx``.
"""
import pythoncom # type: ignore
pythoncom.CoInitialize()
try:
yield
finally:
try:
pythoncom.CoUninitialize()
except Exception:
pass
@dataclass
@@ -65,6 +87,20 @@ def parse_caption_text(text: str) -> CaptionInfo | None:
)
def resume_page_before_cut(cut_page: int) -> int:
"""Next scan starts on the page before the cut. See uno_helpers."""
from .office.uno_helpers import resume_page_before_cut as impl
return impl(cut_page)
def locate_break_row(n_rows: int, page_at, min_page: int = 1):
"""Binary-search the next page jump. See uno_helpers.locate_break_row."""
from .office.uno_helpers import locate_break_row as impl
return impl(n_rows, page_at, min_page)
def find_page_break_row(page_numbers: list[int]) -> int | None:
"""
Return 1-based Word row index where a new page starts.
@@ -90,6 +126,16 @@ def is_orphan_header_break(break_at: int | None, *, had_header: bool) -> bool:
return bool(had_header and break_at == 2)
def continuation_pass_limit(object_count: int, splits_done: int = 0) -> int:
"""How many passes to run. One pass performs one split.
``10 + (tables + listings) + 1`` for each split already made.
"""
count = max(0, int(object_count))
extra = max(0, int(splits_done))
return 10 + count + extra
def is_caption_orphaned(caption_page: int | None, first_row_page: int | None) -> bool:
"""True if «Таблица N — …» sits on an earlier page than the table body.
@@ -142,27 +188,68 @@ def fix_continuations(
if not os.path.isfile(abs_path):
return FixResult(False, message=f"Файл не найден: {abs_path}")
try:
with word_com_apartment():
return _fix_continuations_in_word(
abs_path,
win32com.client,
tables=tables,
listings=listings,
repeat_header=repeat_header,
)
except Exception as exc:
return FixResult(
False,
message=f"Пост-разрыв таблиц не удался: {exc}",
)
def _fix_continuations_in_word(
abs_path: str,
win32com_client,
*,
tables: bool,
listings: bool,
repeat_header: bool,
) -> FixResult:
word = None
doc = None
splits = 0
details: list[str] = []
try:
word = win32com.client.DispatchEx("Word.Application")
word = win32com_client.DispatchEx("Word.Application")
word.Visible = False
word.DisplayAlerts = 0
doc = word.Documents.Open(abs_path, ReadOnly=False)
for _pass in range(MAX_PASSES):
targets = _count_split_targets(doc, tables=tables, listings=listings)
passes_done = 0
# After a cut, pages above it stay put. The next pass starts on the
# page before that cut; a final pass from page 1 checks nothing was missed.
start_page = 1
while passes_done < continuation_pass_limit(targets, splits):
if passes_done >= PASS_SAFETY_CAP:
details.append("skip: pass safety cap")
break
doc.Repaginate()
made = _fix_pass(
made, cut_page = _fix_pass(
doc,
tables=tables,
listings=listings,
repeat_header=repeat_header,
details=details,
start_page=start_page,
)
passes_done += 1
if made == 0:
if start_page > 1:
start_page = 1
details.append("recheck: from page 1")
continue
break
splits += made
if cut_page:
start_page = resume_page_before_cut(cut_page)
details.append(f"resume: page {start_page} after cut at {cut_page}")
skipped = sum(1 for d in details if d.startswith("skip:"))
_clear_section_page_restarts(doc)
@@ -530,11 +617,18 @@ def _fix_pass(
listings: bool,
repeat_header: bool,
details: list[str],
) -> int:
"""One pass over tables (bottom-up). Returns number of splits performed."""
start_page: int = 1,
) -> tuple[int, int | None]:
"""One pass in document order. Splits the first table that crosses a page.
Tables that end before ``start_page`` are skipped. The page jump inside a
table is binary-searched, so a long remainder is not read from row 1.
Returns ``(splits_made, cut_page)``.
"""
made = 0
cut_page: int | None = None
count = int(doc.Tables.Count)
for ti in range(count, 0, -1):
for ti in range(1, count + 1):
try:
table = doc.Tables(ti)
except Exception:
@@ -554,18 +648,28 @@ def _fix_pass(
if caption.kind == "listing" and not listings:
continue
row_count = int(table.Rows.Count)
# A one-cell listing can cross pages inside the cell; its row page is
# only the start, so it must not be skipped on that alone.
single_cell_listing = caption.kind == "listing" and row_count == 1
if start_page > 1 and row_count > 0 and not single_cell_listing:
last_page = _page_at_row(table, row_count)
if last_page is not None and last_page < start_page:
continue
# Legacy single-cell listings: expand to rows so Split works
if caption.kind == "listing" and int(table.Rows.Count) == 1:
if single_cell_listing:
para_pages = _paragraph_page_numbers_in_cell(table)
if find_page_break_row(para_pages) is not None:
if _expand_single_cell_listing_to_rows(table):
details.append(f"expand: listing {caption.number} → {table.Rows.Count} rows")
doc.Repaginate()
row_count = int(table.Rows.Count)
pages = _row_page_numbers(table)
first_page = _page_at_row(table, 1) if row_count else None
# Method: only the title («Таблица N») at page bottom is forbidden.
if caption.kind == "table" and pages:
if is_caption_orphaned(_caption_page_before_table(table), pages[0]):
if caption.kind == "table" and first_page is not None:
if is_caption_orphaned(_caption_page_before_table(table), first_page):
try:
if _page_break_before_table_caption(table):
details.append(
@@ -582,10 +686,14 @@ def _fix_pass(
details.append(f"skip: orphan-caption page break: {exc}")
continue
break_at = find_page_break_row(pages)
break_at, cut_page = locate_break_row(
row_count,
lambda row, current=table: _page_at_row(current, row),
start_page,
)
if break_at is None:
continue
if break_at > int(table.Rows.Count):
if break_at > row_count:
continue
# Vertical merges block Rows()/Table.Split — unmerge, split, then restore.
@@ -600,8 +708,12 @@ def _fix_pass(
)
doc.Repaginate()
table = doc.Tables(ti)
pages = _row_page_numbers(table)
break_at = find_page_break_row(pages)
row_count = int(table.Rows.Count)
break_at, cut_page = locate_break_row(
row_count,
lambda row, current=table: _page_at_row(current, row),
start_page,
)
if break_at is None:
details.append(
f"skip: {caption.kind} {caption.number} "
@@ -650,7 +762,7 @@ def _fix_pass(
except Exception as exc:
details.append(f"skip: after split cannot get continuation table[{ti}+1]: {exc}")
made += 1
continue
return made, cut_page
if vmerge_spans:
try:
@@ -698,8 +810,36 @@ def _fix_pass(
made += 1
details.append(f"split: {caption.kind} {caption.number} @row {break_at}")
return made, cut_page
return made, None
def _count_split_targets(doc, *, tables: bool, listings: bool) -> int:
"""Captioned tables and listings this pass is allowed to split."""
found = 0
try:
count = int(doc.Tables.Count)
except Exception:
return 0
for ti in range(1, count + 1):
try:
table = doc.Tables(ti)
except Exception:
continue
try:
if int(table.NestingLevel) > 1:
continue
except Exception:
pass
caption = _caption_before_table(table)
if caption is None:
continue
if caption.kind == "table" and tables:
found += 1
elif caption.kind == "listing" and listings:
found += 1
return found
return made
def _caption_before_table(table) -> CaptionInfo | None:
text = _paragraph_text_before_table(table)
+202
View File
@@ -0,0 +1,202 @@
"""DFD Гейна–Сарсона: локальный Pillow, без Graphviz."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from md2gost.dfd_gs import (
SAMPLE,
DfdGsParseError,
_arrange,
parse_dfd_gs,
render_dfd_gs,
)
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
configure_diagrams,
is_diagram_lang,
prepare_source,
render_diagram,
)
def test_fence_aliases_do_not_steal_yourdon():
assert is_diagram_lang("dfd-gs")
assert is_diagram_lang("uml-dfd-gs")
assert is_diagram_lang("gane-sarson")
assert is_diagram_lang("uml-gane-sarson")
assert "dfd-gs" in DIAGRAM_LANGS
assert prepare_source("dfd-gs", SAMPLE)[1] == "dfd-gs"
assert prepare_source("gane-sarson", SAMPLE)[1] == "dfd-gs"
assert prepare_source("uml-dfd-gs", SAMPLE)[1] == "dfd-gs"
assert prepare_source("dfd", "entity A Клиент\n")[1] == "dfd"
assert prepare_source("uml-dfd", "entity A Клиент\n")[1] == "dfd"
assert prepare_source("yourdon", "entity A Клиент\n")[1] == "dfd"
def test_badges_and_colon_label():
diagram = parse_dfd_gs(
"""
entity A Художник
entity B Заказчик
process Accept Принять заявку
process 3.1 Проверить срок
store Orders Заказы
store D4 Акты
A --> Accept : файлы работы
"""
)
by_id = {node.id: node for node in diagram.nodes}
assert by_id["A"].badge == "1"
assert by_id["B"].badge == "2"
assert by_id["Accept"].badge == "1"
assert by_id["3.1"].badge == "3.1"
assert by_id["Orders"].badge == "D1"
assert by_id["D4"].badge == "D4"
assert diagram.flows[0].label == "файлы работы"
assert diagram.form == "kit"
assert diagram.title == ""
def test_store_code_skips_taken_numbers():
diagram = parse_dfd_gs(
"""
store D1 Заказы
store Orders Журнал
"""
)
badges = {node.id: node.badge for node in diagram.nodes}
assert badges == {"D1": "D1", "Orders": "D2"}
def test_parse_errors():
with pytest.raises(DfdGsParseError, match="пустой"):
parse_dfd_gs(" \n\n")
with pytest.raises(DfdGsParseError, match="без узлов"):
parse_dfd_gs("# только комментарий\n")
with pytest.raises(DfdGsParseError, match="без узлов"):
parse_dfd_gs("title Только шапка\n")
with pytest.raises(DfdGsParseError, match="повтор"):
parse_dfd_gs("entity A Один\nprocess A Два\n")
with pytest.raises(DfdGsParseError, match="неизвестный узел"):
parse_dfd_gs("entity A Один\nA --> B поток\n")
with pytest.raises(DfdGsParseError, match="в себя"):
parse_dfd_gs("entity A Один\nA --> A петля\n")
with pytest.raises(DfdGsParseError, match="form"):
parse_dfd_gs("form fancy\nentity A Один\n")
with pytest.raises(DfdGsParseError, match="нет подписи"):
parse_dfd_gs("process 1\n")
def test_columns_and_straight_arrow():
diagram = parse_dfd_gs(
"""
entity A Художник
process P Принять
store S Файлы
A --> P заявка
P --> S файл
"""
)
layout = _arrange(diagram, 1)
assert layout.placed["A"].x < layout.placed["P"].x < layout.placed["S"].x
straight = _arrange(
parse_dfd_gs("entity A Художник\nprocess P Принять\nA --> P заявка\n"),
1,
)
pts = straight.routes[0].pts
assert len(pts) == 2
assert abs(pts[0][1] - pts[1][1]) < 1
def test_opposite_flows_do_not_share_a_port():
diagram = parse_dfd_gs(
"""
process P Принять
store S Заказы
P --> S статус
S --> P обновление
"""
)
layout = _arrange(diagram, 1)
y1 = layout.routes[0].pts[0][1]
y2 = layout.routes[1].pts[0][1]
assert abs(y1 - y2) > 10
def test_stacked_processes_link_vertically():
diagram = parse_dfd_gs(
"""
process A Один
process B Два
A --> B дальше
"""
)
pts = _arrange(diagram, 1).routes[0].pts
assert len(pts) == 2
assert abs(pts[0][0] - pts[1][0]) < 1
assert pts[0][1] < pts[1][1]
def test_skipped_process_is_an_elbow():
diagram = parse_dfd_gs(
"""
process A Один
process B Два
process C Три
A --> C дальше
"""
)
pts = _arrange(diagram, 1).routes[0].pts
assert len(pts) >= 3
def test_render_png_and_svg_without_graphviz():
png, svg = render_dfd_gs(parse_dfd_gs(SAMPLE), scale=1)
assert png.startswith(b"\x89PNG")
assert svg is not None
assert "USED AT" in svg
assert "Тарасов" in svg
assert "A44" in svg
assert "rx=" in svg
assert "Художник" in svg
assert "rotate(-90" in svg
assert "D2" in svg
plain = parse_dfd_gs("form plain\nentity A Художник\nprocess P Принять\nA --> P заявка\n")
_png, svg_plain = render_dfd_gs(plain, scale=1)
assert "USED AT" not in (svg_plain or "")
assert "rx=" in (svg_plain or "")
def test_render_diagram_skips_jar_and_kroki(tmp_path):
calls = {"jar": 0, "kroki": 0}
def fake_jar(*_args, **_kwargs):
calls["jar"] += 1
return False
def fake_kroki(*_args, **_kwargs):
calls["kroki"] += 1
return False
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1, diagram_format="svg")
with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram(
"dfd-gs", SAMPLE, cache_dir=str(tmp_path), diagram_scale=1, diagram_format="svg",
)
assert calls["jar"] == 0
assert calls["kroki"] == 0
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
assert result.svg_path and Path(result.svg_path).read_text(encoding="utf-8").startswith("<svg")
assert result.pixel_scale == 1
def test_render_diagram_bad_source(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
with pytest.raises(RuntimeError, match="пустой"):
render_diagram("gane-sarson", " ", cache_dir=str(tmp_path))
+61
View File
@@ -0,0 +1,61 @@
"""Output path when the GUI switches between MD → DOCX and DOCX → MD."""
import os
from md2gost.gui import (
coerce_docx_output,
import_output_path,
next_import_output,
next_markdown_output,
output_tracks_source,
)
from md2gost.pipeline import default_output_path
def test_import_markdown_counts_as_automatic(tmp_path):
docx = tmp_path / "шаблон таблицы.docx"
imported = import_output_path(str(docx))
assert imported.endswith(".md")
assert output_tracks_source(str(docx), imported)
# default_output_path appends .docx and must not be the import target
assert not output_tracks_source(str(docx), default_output_path(str(docx)))
def test_markdown_after_import_uses_docx(tmp_path):
"""MD → DOCX, then DOCX → MD, then another .md must not keep the .md output."""
report = tmp_path / "1исп.md"
imported = import_output_path(str(tmp_path / "шаблон таблицы.docx"))
out, auto = next_markdown_output(str(report), imported, auto=True)
assert out == default_output_path(str(report))
assert auto is True
# Old GUI cleared the auto flag after import, so the .md path stuck.
out, auto = next_markdown_output(str(report), imported, auto=False)
assert out == default_output_path(str(report))
assert out.lower().endswith(".docx")
assert auto is True
def test_custom_docx_is_kept_when_switching_markdown(tmp_path):
other = tmp_path / "other.md"
custom = str(tmp_path / "pinned.docx")
out, auto = next_markdown_output(str(other), custom, auto=False)
assert out == custom
assert auto is False
def test_import_replaces_previous_docx_target(tmp_path):
src = tmp_path / "шаблон таблицы.docx"
previous = str(tmp_path / "report.docx")
out, auto = next_import_output(str(src), previous, auto=False)
assert os.path.normcase(out) == os.path.normcase(import_output_path(str(src)))
assert auto is True
def test_convert_does_not_reuse_import_markdown(tmp_path):
report = tmp_path / "1исп.md"
stale = str(tmp_path / "шаблон таблицы.md")
assert coerce_docx_output(str(report), stale) == default_output_path(str(report))
keep = str(tmp_path / "keep.docx")
assert coerce_docx_output(str(report), keep) == keep
+118
View File
@@ -330,3 +330,121 @@ def test_landscape_listing_deferred_after_section(tmp_path, monkeypatch):
texts = [p.text for p in doc.paragraphs]
listing_caps = [t for t in texts if t.startswith("Листинг")]
assert listing_caps, f"no listing caption in {texts!r}"
def _empty_section_break_pairs(doc) -> int:
"""Pairs of next-page breaks with no body content between them (a blank page)."""
children = list(doc.element.body)
pairs = 0
prev_empty_break = False
for child in children:
if child.tag == f"{W_NS}sectPr":
continue
is_break = _is_break_paragraph(child)
if is_break:
if prev_empty_break:
pairs += 1
prev_empty_break = True
continue
prev_empty_break = False
return pairs
def _fresh_doc():
doc = Document(os.path.join(package_dir(), "Template.docx"))
body = doc._body._element
for child in list(body):
if child.tag.endswith("}sectPr"):
continue
body.remove(child)
return doc
def _small_table(doc, name: str, *, landscape: bool) -> Table:
table = Table(doc._body, 2, 2, CaptionInfo(name, name, landscape=landscape))
for r in range(2):
for c in range(2):
table.add_paragraph_to_cell(r, c).add_run(f"{name}{r}{c}")
return table
def test_consecutive_landscape_tables_share_section():
"""Two +landscape tables in a row must not put a blank portrait page between them."""
doc = _fresh_doc()
Renderer(doc, skip_numbering=True).process([
_small_table(doc, "A", landscape=True),
_small_table(doc, "B", landscape=True),
])
assert _empty_section_break_pairs(doc) == 0
assert [s.orientation for s in doc.sections] == [
WD_ORIENT.PORTRAIT,
WD_ORIENT.LANDSCAPE,
WD_ORIENT.PORTRAIT,
]
assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT
def test_landscape_then_text_then_landscape_returns_to_portrait():
doc = _fresh_doc()
from md2gost.renderable.paragraph import Paragraph
middle = Paragraph(doc._body)
middle.add_run("Между таблицами.")
Renderer(doc, skip_numbering=True).process([
_small_table(doc, "A", landscape=True),
middle,
_small_table(doc, "B", landscape=True),
])
assert _empty_section_break_pairs(doc) == 0
assert [s.orientation for s in doc.sections] == [
WD_ORIENT.PORTRAIT,
WD_ORIENT.LANDSCAPE,
WD_ORIENT.PORTRAIT,
WD_ORIENT.LANDSCAPE,
WD_ORIENT.PORTRAIT,
]
def test_consecutive_landscape_photos_share_section(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
png = tmp_path / "photo.png"
PILImage.new("RGB", (80, 40), color=(0, 128, 255)).save(png)
os.environ["WORKING_DIR"] = str(tmp_path)
from md2gost.renderable.paragraph import Paragraph
doc = _fresh_doc()
first = Paragraph(doc._body)
first.add_image(str(png), CaptionInfo("p1", "Фото 1", landscape=True))
second = Paragraph(doc._body)
second.add_image(str(png), CaptionInfo("p2", "Фото 2", landscape=True))
Renderer(doc, skip_numbering=True).process([first, second])
assert _empty_section_break_pairs(doc) == 0
assert [s.orientation for s in doc.sections] == [
WD_ORIENT.PORTRAIT,
WD_ORIENT.LANDSCAPE,
WD_ORIENT.PORTRAIT,
]
def test_second_landscape_photo_is_not_shrunk_into_leftover(tmp_path, monkeypatch):
"""A follower that does not fit the rest of the page keeps its page size."""
monkeypatch.chdir(tmp_path)
png = tmp_path / "tall.png"
PILImage.new("RGB", (2000, 2000), color=(0, 128, 255)).save(png)
os.environ["WORKING_DIR"] = str(tmp_path)
doc = _fresh_doc()
first = Image(doc._body, str(png), CaptionInfo("p1", "Фото 1", landscape=True))
second = Image(doc._body, str(png), CaptionInfo("p2", "Фото 2", landscape=True))
Renderer(doc, skip_numbering=True).process([first, second])
A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
heights = []
for child in doc.element.body:
ext = child.find(f".//{A}ext")
if ext is not None:
heights.append(int(ext.get("cy")))
assert len(heights) == 2
assert heights[1] > heights[0] * 0.9
+51 -1
View File
@@ -17,6 +17,7 @@ from md2gost.office import (
fix_continuations_with_engine,
resolve_office_engine,
)
from md2gost.office.detect import clear_office_engine_cache
from md2gost.office import uno_fix
from md2gost.office import uno_profile
from md2gost.office.uno_cli import soffice_cli_path
@@ -49,6 +50,13 @@ def test_parser_office_engine_libre():
assert req.office_engine == "libre"
@pytest.fixture(autouse=True)
def _clear_office_cache():
clear_office_engine_cache()
yield
clear_office_engine_cache()
@patch("md2gost.office.detect._detect_word")
@patch("md2gost.office.detect._find_soffice_install")
def test_resolve_auto_prefers_word(mock_lo, mock_word):
@@ -112,6 +120,29 @@ def test_detect_libre_from_custom_path(tmp_path):
assert install.python_path == str(python_bin)
@patch("md2gost.office.detect._word_com_available", return_value=True)
def test_detect_word_without_starting_com(_mock_avail):
install = detect_office_install("word")
assert install is not None
assert install.engine == "word"
@patch("md2gost.office.detect._detect_word")
@patch("md2gost.office.detect._find_soffice_install")
def test_resolve_auto_cached_on_second_call(mock_lo, mock_word):
mock_word.return_value = OfficeInstall(engine="word", label="Microsoft Word")
mock_lo.return_value = OfficeInstall(
engine="libre",
label="LibreOffice",
soffice_path=r"C:\LibreOffice\program\soffice.exe",
python_path=r"C:\LibreOffice\program\python.exe",
)
first = resolve_office_engine("auto")
second = resolve_office_engine("auto")
assert first == second == ("word", mock_word.return_value)
mock_word.assert_called_once()
@patch("md2gost.office.dispatch.fix_continuations")
@patch("md2gost.office.dispatch.resolve_office_engine")
def test_dispatch_word_backend(mock_resolve, mock_word_fix):
@@ -122,6 +153,21 @@ def test_dispatch_word_backend(mock_resolve, mock_word_fix):
mock_word_fix.assert_called_once()
@patch("md2gost.office.dispatch.fix_continuations")
@patch("md2gost.office.dispatch.resolve_office_engine")
def test_dispatch_uses_preresolved_install(mock_resolve, mock_word_fix):
install = OfficeInstall(engine="word", label="Microsoft Word")
mock_word_fix.return_value = FixResult(True, splits=1, message="ok")
result = fix_continuations_with_engine(
"report.docx",
engine="word",
install=install,
)
assert result.ok is True
mock_resolve.assert_not_called()
mock_word_fix.assert_called_once()
@patch("md2gost.office.dispatch.fix_continuations_uno")
@patch("md2gost.office.dispatch.resolve_office_engine")
def test_dispatch_libre_backend(mock_resolve, mock_uno):
@@ -199,7 +245,9 @@ def test_uno_call_method_or_attribute():
assert uno_call(_DocAttr(), "Text") == "attr"
def test_uno_worker_env_includes_program_dir():
def test_uno_worker_env_includes_program_dir(monkeypatch):
monkeypatch.setenv("PYTHONHOME", r"C:\Python310")
monkeypatch.setenv("PYTHONPATH", r"C:\Python310\Lib")
install = OfficeInstall(
engine="openoffice",
label="OpenOffice",
@@ -209,6 +257,8 @@ def test_uno_worker_env_includes_program_dir():
worker_dir = Path(r"C:\temp\worker")
with patch.object(uno_fix, "_office_program_dir", return_value=Path(r"C:\OpenOffice 4\program")):
env, cwd = uno_fix._uno_worker_env(worker_dir, install)
assert "PYTHONHOME" not in env
assert r"C:\Python310\Lib" not in env["PYTHONPATH"]
assert r"C:\OpenOffice 4\program" in env["PYTHONPATH"]
assert str(worker_dir) in env["PYTHONPATH"]
assert cwd == Path(r"C:\OpenOffice 4\program")
+44
View File
@@ -62,6 +62,39 @@ def test_parse_colspan_markers():
assert data[2].merge_h == "none"
SAMPLE_LT_HEADER = """\
| Ситуация | < | < | < |
|---|---|---|---|
| Шаг | a | b | c |
"""
SAMPLE_LT_BODY = """\
| A | B | C |
|---|---|---|
| wide | < | Z |
"""
def test_parse_lt_colspan_in_header():
doc = markdown.parse(SAMPLE_LT_HEADER)
t = next(c for c in doc.children if isinstance(c, MdTable))
header = t.children[0].children
assert header[0].header is True
assert header[0].merge_h == "restart"
assert [c.merge_h for c in header[1:]] == ["continue", "continue", "continue"]
body = t.children[1].children
assert [c.merge_h for c in body] == ["none", "none", "none", "none"]
def test_parse_lt_colspan_in_body():
doc = markdown.parse(SAMPLE_LT_BODY)
t = next(c for c in doc.children if isinstance(c, MdTable))
data = t.children[1].children
assert data[0].merge_h == "restart"
assert data[1].merge_h == "continue"
assert data[2].merge_h == "none"
def test_apply_cell_merge_oxml():
doc = Document()
table = create_table(doc, 0, 2, doc.sections[0].page_width)
@@ -153,6 +186,17 @@ def test_checker_merge_col_error():
def test_checker_merge_ok():
issues = check_table_merge(SAMPLE_ROWSPAN)
assert issues == []
assert check_table_merge(SAMPLE_LT_HEADER) == []
def test_checker_lt_first_column():
bad = """\
| A | B |
|---|---|
| < | 2 |
"""
issues = check_table_merge(bad)
assert any(i.id == "table.merge_col" for i in issues)
def test_table_continuation_modes():
+127
View File
@@ -14,8 +14,11 @@ from md2gost.word_fix import (
find_page_break_row,
fix_continuations,
is_caption_orphaned,
continuation_pass_limit,
is_orphan_header_break,
locate_break_row,
parse_caption_text,
resume_page_before_cut,
split_vmerge_spans_at_break,
)
@@ -61,6 +64,68 @@ def test_find_page_break_row():
assert find_page_break_row([14, 14, 14, 14, 14, 14, 15, 15]) == 7
def test_resume_page_before_cut():
assert resume_page_before_cut(1) == 1
assert resume_page_before_cut(2) == 1
assert resume_page_before_cut(15) == 14
def test_locate_break_row_matches_full_scan_from_page_1():
samples = [
[1, 1, 2, 2],
[2, 2, 3],
[1, 2],
[14, 14, 14, 14, 14, 14, 15, 15],
[1, 1, 1],
[8] * 30 + [9] * 10 + [12] * 5,
]
for pages in samples:
def page_at(row, pages=pages):
return pages[row - 1]
found, cut = locate_break_row(len(pages), page_at, 1)
assert found == find_page_break_row(pages)
if found:
assert cut == pages[found - 1]
def test_locate_break_row_starts_at_page_before_cut():
"""A long table is not read from row 1 when the window starts near the cut."""
pages = [10] * 80 + [14] * 8 + [15] * 120
probes: list[int] = []
def page_at(row):
probes.append(row)
return pages[row - 1]
found, cut = locate_break_row(len(pages), page_at, min_page=14)
assert found == 81
assert cut == 14
assert len(probes) < 40
continuation = [15] * 12 + [16] * 200
cont_probes: list[int] = []
def continuation_at(row):
cont_probes.append(row)
return continuation[row - 1]
found, cut = locate_break_row(
len(continuation),
continuation_at,
min_page=resume_page_before_cut(15),
)
assert (found, cut) == (13, 16)
assert len(cont_probes) < 30
def test_continuation_pass_limit():
assert continuation_pass_limit(0, 0) == 10
assert continuation_pass_limit(3, 0) == 13
assert continuation_pass_limit(3, 1) == 14
assert continuation_pass_limit(3, 5) == 18
def test_orphan_header_and_caption_guards():
"""Method guide: no title-only / title+column-headers alone at page end."""
# Split would leave only heading row on previous page
@@ -104,6 +169,68 @@ def test_fix_continuations_missing_file():
assert "не найден" in r.message.lower() or "Файл" in r.message
def test_word_com_apartment_pairs_init(monkeypatch):
import types
calls = []
fake = types.ModuleType("pythoncom")
fake.CoInitialize = lambda: calls.append("init")
fake.CoUninitialize = lambda: calls.append("uninit")
monkeypatch.setitem(sys.modules, "pythoncom", fake)
from md2gost.word_fix import word_com_apartment
with word_com_apartment():
calls.append("body")
assert calls == ["init", "body", "uninit"]
def test_word_com_apartment_skips_uninit_when_init_fails(monkeypatch):
import types
calls = []
fake = types.ModuleType("pythoncom")
def boom():
raise RuntimeError("coinit")
fake.CoInitialize = boom
fake.CoUninitialize = lambda: calls.append("uninit")
monkeypatch.setitem(sys.modules, "pythoncom", fake)
from md2gost.word_fix import word_com_apartment
with pytest.raises(RuntimeError, match="coinit"):
with word_com_apartment():
calls.append("body")
assert calls == []
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
def test_second_thread_can_dispatch_com():
"""A fresh thread must call CoInitialize itself; module import does it once."""
try:
import win32com.client # noqa: F401
except ImportError:
pytest.skip("pywin32 not installed")
import threading
from md2gost.word_fix import word_com_apartment
errors: list[BaseException] = []
def work():
try:
with word_com_apartment():
win32com.client.Dispatch("Scripting.Dictionary")
except Exception as exc:
errors.append(exc)
for _ in range(2):
thread = threading.Thread(target=work)
thread.start()
thread.join()
assert errors == []
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
@pytest.mark.skipif(os.environ.get("MD2GOST_TEST_WORD") != "1", reason="set MD2GOST_TEST_WORD=1 to run Word COM smoke")
def test_fix_continuations_com_smoke(tmp_path):