diff --git a/.cursor/plans/newgui_technology_stack_a6f38fd9.plan.md b/.cursor/plans/newgui_technology_stack_a6f38fd9.plan.md
new file mode 100644
index 0000000..b6d44a0
--- /dev/null
+++ b/.cursor/plans/newgui_technology_stack_a6f38fd9.plan.md
@@ -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.
diff --git a/AI-Docs_WriteHuman/context/diagrams.md b/AI-Docs_WriteHuman/context/diagrams.md
index 511b3ee..4c8fc03 100644
--- a/AI-Docs_WriteHuman/context/diagrams.md
+++ b/AI-Docs_WriteHuman/context/diagrams.md
@@ -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 — качество растра).
diff --git a/AI-Docs_WriteHuman/prompts/report-pis.md b/AI-Docs_WriteHuman/prompts/report-pis.md
index f7e9d2b..9a23651 100644
--- a/AI-Docs_WriteHuman/prompts/report-pis.md
+++ b/AI-Docs_WriteHuman/prompts/report-pis.md
@@ -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` — вывести исходник схемы отдельным Листингом.
diff --git a/NewGUI/DESIGN.md b/NewGUI/DESIGN.md
new file mode 100644
index 0000000..e943c7d
--- /dev/null
+++ b/NewGUI/DESIGN.md
@@ -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.
\ No newline at end of file
diff --git a/NewGUI/code.html b/NewGUI/code.html
new file mode 100644
index 0000000..0f27e7c
--- /dev/null
+++ b/NewGUI/code.html
@@ -0,0 +1,442 @@
+
+
+
+save_as main
data_object UTF-8
Ln 142, Col 28
3,480 слов
description 24 стр. (A4)
Local Kroki (Ready) | plantuml.jar (v1.2024)
Таблицы: Word COM (Active)
Заполнение: 94%
zoom_out 100% zoom_in
\ No newline at end of file
diff --git a/NewGUI/screen.png b/NewGUI/screen.png
new file mode 100644
index 0000000..6200245
Binary files /dev/null and b/NewGUI/screen.png differ
diff --git a/PROJECT.md b/PROJECT.md
index ee6f10c..c921b74 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -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` даёт ложные срабатывания; это эвристика.
diff --git a/_tmp_kr.docx b/_tmp_kr.docx
new file mode 100644
index 0000000..7f6b767
Binary files /dev/null and b/_tmp_kr.docx differ
diff --git a/_tmp_kr_fixed.docx b/_tmp_kr_fixed.docx
new file mode 100644
index 0000000..33fe93f
Binary files /dev/null and b/_tmp_kr_fixed.docx differ
diff --git a/_tmp_kr_nosplit.docx b/_tmp_kr_nosplit.docx
new file mode 100644
index 0000000..2a2237b
Binary files /dev/null and b/_tmp_kr_nosplit.docx differ
diff --git a/desktop/.gitignore b/desktop/.gitignore
new file mode 100644
index 0000000..ec66e4f
--- /dev/null
+++ b/desktop/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.wordcraft/
+out/
+dist/
+*.tsbuildinfo
diff --git a/desktop/electron.vite.config.ts b/desktop/electron.vite.config.ts
new file mode 100644
index 0000000..a3b9367
--- /dev/null
+++ b/desktop/electron.vite.config.ts
@@ -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()],
+ },
+})
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
new file mode 100644
index 0000000..89d85eb
--- /dev/null
+++ b/desktop/package-lock.json
@@ -0,0 +1,4333 @@
+{
+ "name": "ghosteditor-desktop",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ghosteditor-desktop",
+ "version": "0.1.0",
+ "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"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz",
+ "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
+ "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz",
+ "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.9",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.11.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
+ "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.7.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-css": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+ "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/css": "^1.1.7"
+ }
+ },
+ "node_modules/@codemirror/lang-html": {
+ "version": "6.4.12",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
+ "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/css": "^1.1.0",
+ "@lezer/html": "^1.3.12"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
+ "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-markdown": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
+ "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.7.1",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.3.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/markdown": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.13.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
+ "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/streamparser": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.42.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.7.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
+ "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/streamparser": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
+ "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
+ "license": "MIT"
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.14",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
+ "integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.7.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
+ "node_modules/@electron-internal/extract-zip": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
+ "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@electron/get": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
+ "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^3.0.0",
+ "graceful-fs": "^4.2.11",
+ "progress": "^2.0.3",
+ "semver": "^7.6.3",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "optionalDependencies": {
+ "undici": "^7.24.4"
+ }
+ },
+ "node_modules/@electron/get/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+ "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+ "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/core": "^1.8.0",
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@fontsource/inter": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
+ "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/jetbrains-mono": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
+ "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/space-grotesk": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.3.0.tgz",
+ "integrity": "sha512-ksnGizDPXIDuvqcTYTSrmZ+evx9sDlS8rp7+42BQ7wU+spt3twEoXfbJz672C+5CLg6VeUQwRy5RXshWb67LcQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
+ "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/css": {
+ "version": "1.3.9",
+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
+ "integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
+ "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/html": {
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
+ "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
+ "integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.11",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
+ "integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/markdown": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.0.tgz",
+ "integrity": "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
+ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.5.tgz",
+ "integrity": "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.5.tgz",
+ "integrity": "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.5.tgz",
+ "integrity": "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.5.tgz",
+ "integrity": "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.5.tgz",
+ "integrity": "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.5.tgz",
+ "integrity": "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.5.tgz",
+ "integrity": "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.5.tgz",
+ "integrity": "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.5.tgz",
+ "integrity": "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.5.tgz",
+ "integrity": "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.5.tgz",
+ "integrity": "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.5.tgz",
+ "integrity": "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.5.tgz",
+ "integrity": "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.5.tgz",
+ "integrity": "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.5.tgz",
+ "integrity": "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.5.tgz",
+ "integrity": "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.5.tgz",
+ "integrity": "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.5.tgz",
+ "integrity": "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.5.tgz",
+ "integrity": "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.5.tgz",
+ "integrity": "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.5.tgz",
+ "integrity": "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.5.tgz",
+ "integrity": "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.5.tgz",
+ "integrity": "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.5.tgz",
+ "integrity": "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.5.tgz",
+ "integrity": "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tiptap/core": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz",
+ "integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz",
+ "integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-bold": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz",
+ "integrity": "sha512-QX1xoXvO0gz6uVsoLegumqQ4MJXL7lcUDQZ4J7la7LBqiIh4efrCmp2EXbhpr37M/YQL7w8q3g7H/VovSh7xIg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.4.tgz",
+ "integrity": "sha512-Ml/yswuys+3QkOMajfXyAGlUKCxEbT3dMyF8vHwQ5mDk9MDm+dqp10It5CrDBkUT/KkVTa6AJI3kb0TXZJZiAA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz",
+ "integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz",
+ "integrity": "sha512-pzi8GNDs5J1DEoO1HNYiJ5WZpgOZzlc/2VIa77mMpEuD4hZxuK0BQzlF/O6Ps/O5P1lGJT4xS0Yowpzb9mD9RA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz",
+ "integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-document": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz",
+ "integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz",
+ "integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz",
+ "integrity": "sha512-pKzulkDmolpVt7IP9FSPsC5pKbXQ5pyeqkaAYr8cyOVW+KUObpPRIzAuXvEso8PCjWgwJ1f2qyishOEMw8MbfQ==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz",
+ "integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz",
+ "integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz",
+ "integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz",
+ "integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz",
+ "integrity": "sha512-pftTQBPYZocNDAj/u6OyDIp7YxmN0+N895cQPogJQ6qMHdLiRhzgawiEewJkniyN1w8k3Wx/Etp8pTKrxaOOBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz",
+ "integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz",
+ "integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz",
+ "integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz",
+ "integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz",
+ "integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz",
+ "integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz",
+ "integrity": "sha512-4MBr63TJIMqxCjE+gAPdlY1IdELgvbAP516K3Ntn/BkcpEbEoPMEYVz+XTxULyHQEuARDO6jsXPPxRXpA74jaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz",
+ "integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.4.tgz",
+ "integrity": "sha512-0iFGPIqmqGkCrOFh1OPo7Dzv5s86dVN3bZaugsz0jK04otkb8bn04ZDKP2/6Wq+lcK0M9DfJ0QqfW5hC2nBZxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz",
+ "integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/markdown": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.31.4.tgz",
+ "integrity": "sha512-lmR9V1/eQ41BaR2zBgYG/+Liy7K7Ch17ZNxVy8dpUVlNpWxxoHUMXNsCg2uEyXP3NLpZaKiY0+drSu9oK1DbSA==",
+ "license": "MIT",
+ "dependencies": {
+ "marked": "^17.0.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz",
+ "integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.4.1",
+ "prosemirror-commands": "^1.7.1",
+ "prosemirror-dropcursor": "^1.8.2",
+ "prosemirror-gapcursor": "^1.4.1",
+ "prosemirror-history": "^1.5.0",
+ "prosemirror-inputrules": "^1.5.1",
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.11",
+ "prosemirror-schema-list": "^1.5.1",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-tables": "^1.8.5",
+ "prosemirror-transform": "^1.12.0",
+ "prosemirror-view": "^1.42.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/react": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.4.tgz",
+ "integrity": "sha512-mBvvXgdPhMinT1NPfwHvFK1dFdMXHVWMSeNn0IPj7/zV0XBmOnASkEOwoIvl8NluKYEtpZBE1yA+6pN2enLa5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "fast-equals": "^5.3.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "optionalDependencies": {
+ "@tiptap/extension-bubble-menu": "3.31.4",
+ "@tiptap/extension-floating-menu": "3.31.4"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/pm": "3.31.4",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "3.31.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz",
+ "integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "3.31.4",
+ "@tiptap/extension-blockquote": "3.31.4",
+ "@tiptap/extension-bold": "3.31.4",
+ "@tiptap/extension-bullet-list": "3.31.4",
+ "@tiptap/extension-code": "3.31.4",
+ "@tiptap/extension-code-block": "3.31.4",
+ "@tiptap/extension-document": "3.31.4",
+ "@tiptap/extension-dropcursor": "3.31.4",
+ "@tiptap/extension-gapcursor": "3.31.4",
+ "@tiptap/extension-hard-break": "3.31.4",
+ "@tiptap/extension-heading": "3.31.4",
+ "@tiptap/extension-horizontal-rule": "3.31.4",
+ "@tiptap/extension-italic": "3.31.4",
+ "@tiptap/extension-link": "3.31.4",
+ "@tiptap/extension-list": "3.31.4",
+ "@tiptap/extension-list-item": "3.31.4",
+ "@tiptap/extension-list-keymap": "3.31.4",
+ "@tiptap/extension-ordered-list": "3.31.4",
+ "@tiptap/extension-paragraph": "3.31.4",
+ "@tiptap/extension-strike": "3.31.4",
+ "@tiptap/extension-text": "3.31.4",
+ "@tiptap/extension-underline": "3.31.4",
+ "@tiptap/extensions": "3.31.4",
+ "@tiptap/pm": "3.31.4"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
+ "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.28",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz",
+ "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001815",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
+ "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/crelt": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/electron": {
+ "version": "44.7.0",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-44.7.0.tgz",
+ "integrity": "sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron-internal/extract-zip": "^1.0.1",
+ "@electron/get": "^5.0.0",
+ "@types/node": "^24.9.0"
+ },
+ "bin": {
+ "electron": "cli.js",
+ "install-electron": "install.js"
+ },
+ "engines": {
+ "node": ">= 22.12.0"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.452",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz",
+ "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/electron-vite": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz",
+ "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.4",
+ "@babel/plugin-transform-arrow-functions": "^7.27.1",
+ "cac": "^6.7.14",
+ "esbuild": "^0.25.11",
+ "magic-string": "^0.30.19",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "electron-vite": "bin/electron-vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@swc/core": "^1.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron/node_modules/@types/node": {
+ "version": "24.19.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
+ "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/electron/node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.26.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
+ "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
+ "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-equals": {
+ "version": "5.4.4",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.4.tgz",
+ "integrity": "sha512-d9/I4rfMfUlO9HOkQqLJgfyRM4siqk1SF3Gjxm4OAMZzV+kCKHe1t/bHxn3xtCPHs3HCQFcvqzkTuanzqECQxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/linkifyjs": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
+ "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/marked": {
+ "version": "17.0.6",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
+ "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/material-symbols": {
+ "version": "0.48.0",
+ "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.48.0.tgz",
+ "integrity": "sha512-onKVWr4wcIWRbm+u+vkICQkw68KLqR2NRNhI0eafWvkE1FnHXHsQ8yUuSpkmVeARhbgVB0G/5ItfMSresLsBFw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.58",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz",
+ "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/prosemirror-changeset": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
+ "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
+ "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
+ "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+ "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
+ "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.12",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
+ "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
+ "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.42.6",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
+ "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.12",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.64.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.5.tgz",
+ "integrity": "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.64.5",
+ "@rollup/rollup-android-arm64": "4.64.5",
+ "@rollup/rollup-darwin-arm64": "4.64.5",
+ "@rollup/rollup-darwin-x64": "4.64.5",
+ "@rollup/rollup-freebsd-arm64": "4.64.5",
+ "@rollup/rollup-freebsd-x64": "4.64.5",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.5",
+ "@rollup/rollup-linux-arm-musleabihf": "4.64.5",
+ "@rollup/rollup-linux-arm64-gnu": "4.64.5",
+ "@rollup/rollup-linux-arm64-musl": "4.64.5",
+ "@rollup/rollup-linux-loong64-gnu": "4.64.5",
+ "@rollup/rollup-linux-loong64-musl": "4.64.5",
+ "@rollup/rollup-linux-ppc64-gnu": "4.64.5",
+ "@rollup/rollup-linux-ppc64-musl": "4.64.5",
+ "@rollup/rollup-linux-riscv64-gnu": "4.64.5",
+ "@rollup/rollup-linux-riscv64-musl": "4.64.5",
+ "@rollup/rollup-linux-s390x-gnu": "4.64.5",
+ "@rollup/rollup-linux-x64-gnu": "4.64.5",
+ "@rollup/rollup-linux-x64-musl": "4.64.5",
+ "@rollup/rollup-openbsd-x64": "4.64.5",
+ "@rollup/rollup-openharmony-arm64": "4.64.5",
+ "@rollup/rollup-win32-arm64-msvc": "4.64.5",
+ "@rollup/rollup-win32-ia32-msvc": "4.64.5",
+ "@rollup/rollup-win32-x64-gnu": "4.64.5",
+ "@rollup/rollup-win32-x64-msvc": "4.64.5",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/style-mod": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
+ "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
+ "license": "MIT"
+ },
+ "node_modules/sumchecker": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
+ "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.30.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
+ "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
+ "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+ "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz",
+ "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/desktop/package.json b/desktop/package.json
new file mode 100644
index 0000000..79a65b6
--- /dev/null
+++ b/desktop/package.json
@@ -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"
+ }
+}
diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts
new file mode 100644
index 0000000..76dde7b
--- /dev/null
+++ b/desktop/src/main/index.ts
@@ -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()
+})
diff --git a/desktop/src/main/wordcraft.ts b/desktop/src/main/wordcraft.ts
new file mode 100644
index 0000000..4f6cd23
--- /dev/null
+++ b/desktop/src/main/wordcraft.ts
@@ -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 {
+ 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 }
+}
diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts
new file mode 100644
index 0000000..8028d7f
--- /dev/null
+++ b/desktop/src/preload/index.ts
@@ -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,
+ save: (text) => ipcRenderer.invoke('host:save', text) as Promise,
+ check: (text) => ipcRenderer.invoke('host:check', text) as Promise,
+ build: (text) => ipcRenderer.invoke('host:build', text) as Promise,
+ edit: (files, activeId) => ipcRenderer.invoke('host:edit', files, activeId) as Promise,
+}
+
+const chrome: ChromeApi = {
+ platform: process.platform,
+ minimize: () => ipcRenderer.invoke('window:minimize') as Promise,
+ toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize') as Promise,
+ close: () => ipcRenderer.invoke('window:close') as Promise,
+}
+
+contextBridge.exposeInMainWorld('host', host)
+contextBridge.exposeInMainWorld('frame', chrome)
diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html
new file mode 100644
index 0000000..43733a3
--- /dev/null
+++ b/desktop/src/renderer/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ GhostEditor
+
+
+
+
+
+
diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx
new file mode 100644
index 0000000..134e5a0
--- /dev/null
+++ b/desktop/src/renderer/src/App.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+function Shell() {
+ return (
+
+ )
+}
diff --git a/desktop/src/renderer/src/components/EditorHost.tsx b/desktop/src/renderer/src/components/EditorHost.tsx
new file mode 100644
index 0000000..d9a8428
--- /dev/null
+++ b/desktop/src/renderer/src/components/EditorHost.tsx
@@ -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(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 (
+
+
+ {text.trimStart().startsWith('
+
+
+ ) : null}
+
+ setCursor({ line, col })}
+ />
+
+
+
+ {settings.split ? (
+
+ ) : null}
+
+ )
+}
diff --git a/desktop/src/renderer/src/components/HelpPanel.tsx b/desktop/src/renderer/src/components/HelpPanel.tsx
new file mode 100644
index 0000000..ebd6bd0
--- /dev/null
+++ b/desktop/src/renderer/src/components/HelpPanel.tsx
@@ -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 (
+
+
event.stopPropagation()}
+ >
+
+ {HELP_TOPICS.map((item) => (
+ 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}
+
+ ))}
+
+
+
+
{shown.title}
+
+ Закрыть
+
+
+
+ {shown.paragraphs.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/desktop/src/renderer/src/components/Icon.tsx b/desktop/src/renderer/src/components/Icon.tsx
new file mode 100644
index 0000000..ce94ff3
--- /dev/null
+++ b/desktop/src/renderer/src/components/Icon.tsx
@@ -0,0 +1,7 @@
+export function Icon({ name, size = 16 }: { name: string; size?: number }) {
+ return (
+
+ {name}
+
+ )
+}
diff --git a/desktop/src/renderer/src/components/MenuBar.tsx b/desktop/src/renderer/src/components/MenuBar.tsx
new file mode 100644
index 0000000..64fabfc
--- /dev/null
+++ b/desktop/src/renderer/src/components/MenuBar.tsx
@@ -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(null)
+ const [about, setAbout] = useState(false)
+ const [help, setHelp] = useState(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): Promise {
+ 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 (
+
+
+ openMenu('file')} onHover={() => menu && setMenu('file')} />
+ {menu === 'file' ? (
+
+ - void host(() => window.host.open())} />
+
- void host(() => window.host.save(text))} />
+
-
+ void host(() =>
+ window.host.edit(
+ Object.values(files).map((file) => ({ id: file.id, text: file.id === activeId ? text : file.text })),
+ activeId,
+ ),
+ )
+ }
+ />
+
+ - {
+ openSettings()
+ setMenu(null)
+ }}
+ />
+
+ - {
+ setMenu(null)
+ void window.frame.close()
+ }}
+ />
+
+ ) : null}
+
+
+ openMenu('edit')} onHover={() => menu && setMenu('edit')} />
+ {menu === 'edit' ? (
+
+ - edit('undo')} />
+
- edit('redo')} />
+
+ ) : null}
+
+
+ openMenu('select')}
+ onHover={() => menu && setMenu('select')}
+ />
+ {menu === 'select' ? (
+
+ - edit('selectAll')} />
+
+ ) : null}
+
+
+ openMenu('view')} onHover={() => menu && setMenu('view')} />
+ {menu === 'view' ? (
+
+ - {
+ updateSettings({ split: !settings.split })
+ setMenu(null)
+ }}
+ />
+
- {
+ if (!settings.split) updateSettings({ split: true, syncScroll: true })
+ else updateSettings({ syncScroll: !settings.syncScroll })
+ setMenu(null)
+ }}
+ />
+
+ - updateSettings({ showLineNumbers: !settings.showLineNumbers })}
+ />
+
- updateSettings({ lineWrapping: !settings.lineWrapping })}
+ />
+
- updateSettings({ highlightLine: !settings.highlightLine })}
+ />
+
+ - {
+ updateSettings({ zoom: Math.min(180, settings.zoom + 10) })
+ setMenu(null)
+ }}
+ />
+
- {
+ updateSettings({ zoom: Math.max(70, settings.zoom - 10) })
+ setMenu(null)
+ }}
+ />
+
+ ) : null}
+
+
+ openMenu('help')} onHover={() => menu && setMenu('help')} />
+ {menu === 'help' ? (
+
+ {HELP_TOPICS.map((topic) => (
+ - {
+ setMenu(null)
+ setHelp(topic)
+ }}
+ />
+ ))}
+
- {
+ setMenu(null)
+ setAbout(true)
+ }}
+ />
+
+ ) : null}
+
+
+ {help ?
setHelp(null)} /> : null}
+ {about ? (
+ setAbout(false)}>
+
event.stopPropagation()}
+ >
+
GhostEditor
+
+ Черновик отчёта пишется здесь. Страницу с полями, стилями и лентой открывает WordCraft. Сборку по ГОСТ по-прежнему
+ делает конвейер md2gost.
+
+
setAbout(false)}
+ className="mt-4 h-7 rounded bg-primary-container px-3 font-code text-label-sm text-on-primary-container"
+ >
+ Закрыть
+
+
+
+ ) : null}
+
+ )
+}
+
+function MenuButton({
+ label,
+ open,
+ onClick,
+ onHover,
+}: {
+ label: string
+ open: boolean
+ onClick: () => void
+ onHover: () => void
+}) {
+ return (
+
+ {label}
+
+ )
+}
+
+function Drop({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function Item({
+ label,
+ shortcut,
+ checked,
+ onClick,
+}: {
+ label: string
+ shortcut?: string
+ checked?: boolean
+ onClick: () => void
+}) {
+ return (
+
+ {checked ? : null}
+ {label}
+ {shortcut ? {shortcut} : null}
+
+ )
+}
+
+function Rule() {
+ return
+}
diff --git a/desktop/src/renderer/src/components/SettingsPanel.tsx b/desktop/src/renderer/src/components/SettingsPanel.tsx
new file mode 100644
index 0000000..e8b51dc
--- /dev/null
+++ b/desktop/src/renderer/src/components/SettingsPanel.tsx
@@ -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('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 (
+
+
+
+ Настройки
+
+
+
+
+
+
+
+ {CATEGORIES.map((item) => (
+ 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}
+
+ ))}
+
+
+ {category === 'editor' ? (
+
+ updateSettings({ fontSize })}
+ />
+ updateSettings({ gutterSize })}
+ />
+ updateSettings({ showLineNumbers })}
+ />
+ updateSettings({ lineWrapping })}
+ />
+ updateSettings({ highlightLine })}
+ />
+
+ Сбросить редактор
+
+
+ ) : null}
+ {category === 'view' ? (
+
+ updateSettings({ zoom })}
+ />
+ updateSettings({ split })}
+ />
+ updateSettings({ syncScroll, split: syncScroll ? true : settings.split })}
+ />
+
+ ) : null}
+ {category === 'preview' ? (
+
+ updateSettings({ previewFontSize })}
+ />
+ updateSettings({ previewSpacing })}
+ />
+ updateSettings({ previewMargin })}
+ />
+
+ Сбросить предпросмотр
+
+
+ ) : null}
+ {category === 'document' ? (
+
+
updateSettings({ student })}
+ />
+ updateSettings({ group })}
+ />
+ Профиль документа выбирается в левой панели.
+
+ ) : null}
+
+
+
+ )
+}
+
+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}
+
+ {value}
+ {suffix}
+
+
+ onChange(Number(event.target.value))}
+ className="accent-primary"
+ />
+
+ )
+}
+
+function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
+ return (
+
+ onChange(event.target.checked)} className="accent-primary" />
+ {label}
+
+ )
+}
+
+function Choice({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string
+ value: T
+ options: ReadonlyArray<{ id: T; label: string }>
+ onChange: (value: T) => void
+}) {
+ return (
+
+
{label}
+
+ {options.map((option) => (
+ 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}
+
+ ))}
+
+
+ )
+}
+
+function TextField({
+ label,
+ value,
+ placeholder,
+ onChange,
+}: {
+ label: string
+ value: string
+ placeholder: string
+ onChange: (value: string) => void
+}) {
+ return (
+
+ {label}
+ 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"
+ />
+
+ )
+}
diff --git a/desktop/src/renderer/src/components/Sidebar.tsx b/desktop/src/renderer/src/components/Sidebar.tsx
new file mode 100644
index 0000000..2bd2e39
--- /dev/null
+++ b/desktop/src/renderer/src/components/Sidebar.tsx
@@ -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('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): 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 (
+
+
+ {VIEWS.map((item) => {
+ const active = panelOpen && view === item.id
+ return (
+ 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 ? : null}
+
+
+ )
+ })}
+
+ {settingsOpen ? : null}
+
+
+
+ {panelOpen ? (
+
+
+ {view === 'files' ?
: null}
+ {view === 'search' ?
: null}
+ {view === 'outline' ?
: null}
+
+
+ ) : null}
+
+ )
+}
+
+function Explorer() {
+ const { text, files, folders, activeId, openFile, createFile, createFolder, activity, setMode, focusLineAt } = useSession()
+ const [expanded, setExpanded] = useState>(() => new Set(DEFAULT_EXPANDED))
+ const [selected, setSelected] = useState(activeId)
+ const [sections, setSections] = useState({ files: true, outline: false, log: false })
+ const treeRef = useRef(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): 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 (
+
+
+ {
+ createFile()
+ }}
+ />
+ {
+ const id = createFolder()
+ setExpanded((prev) => new Set(prev).add('dir:mirea').add(id))
+ setSelected(id)
+ }}
+ />
+ {
+ setExpanded(new Set(DEFAULT_EXPANDED))
+ setSelected(activeId)
+ }}
+ />
+ {
+ setExpanded(new Set())
+ setSelected((current) => (roots.some((node) => node.id === current) ? current : 'dir:mirea'))
+ }}
+ />
+
+
+
setSections((prev) => ({ ...prev, files: !prev.files }))}
+ >
+
+ {rows.map(({ node, depth }) => (
+ {
+ setSelected(node.id)
+ if (node.kind === 'folder') toggle(node.id)
+ else openFile(node.id)
+ treeRef.current?.focus()
+ }}
+ />
+ ))}
+
+
+
setSections((prev) => ({ ...prev, outline: !prev.outline }))}
+ >
+
+
+
setSections((prev) => ({ ...prev, log: !prev.log }))}
+ >
+ {activity.length === 0 ? (
+ Действий ещё не было
+ ) : (
+ activity.map((entry, index) => (
+
+ {entry}
+
+ ))
+ )}
+
+
+
+ )
+}
+
+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(null)
+ const inputRef = useRef(null)
+ const needle = query.trim().toLocaleLowerCase('ru')
+ const hits = needle ? searchHits(Object.values(files), needle) : []
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ return (
+
+
+
+ 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"
+ />
+
+
+ {!needle ?
Введите имя файла или фрагмент текста
: null}
+ {needle && hits.length === 0 ?
Ничего не найдено
: null}
+ {hits.map((hit) => (
+
{
+ 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'
+ }`}
+ >
+
+
+
+ {highlightName(hit.label, query)}
+
+ {hit.line ? `:${hit.line}` : hit.directory}
+
+
+ ))}
+
+
+ )
+}
+
+function OutlinePanel() {
+ const { text, setMode, focusLineAt } = useSession()
+ const headings = documentHeadings(text)
+
+ return (
+
+
+
+ {
+ setMode('code')
+ focusLineAt(line)
+ }}
+ />
+
+
+ )
+}
+
+function ViewHeader({ title, children }: { title: string; children?: ReactNode }) {
+ return (
+
+
+ {title}
+
+
{children}
+
+ )
+}
+
+function HeaderButton({ icon, title, onClick }: { icon: string; title: string; onClick?: () => void }) {
+ return (
+
+
+
+ )
+}
+
+function Section({
+ title,
+ open,
+ onToggle,
+ grow = false,
+ ruled = false,
+ children,
+}: {
+ title: string
+ open: boolean
+ onToggle: () => void
+ grow?: boolean
+ ruled?: boolean
+ children?: ReactNode
+}) {
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ {open ? (
+ {children}
+ ) : null}
+
+ )
+}
+
+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 (
+
+ {Array.from({ length: depth }, (_, level) => (
+
+ ))}
+
+ {folder ? : null}
+
+
+
+
+ {node.name}
+ {dirty ? (
+
+ M
+
+
+ ) : openInEditor ? (
+
+ ) : null}
+
+ )
+}
+
+function HeadingList({ headings, onJump }: { headings: Heading[]; onJump: (line: number) => void }) {
+ if (headings.length === 0) {
+ return В документе нет заголовков
+ }
+ return (
+
+ {headings.map((heading) => (
+ 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 }}
+ >
+ {heading.level}
+ {heading.title}
+
+ ))}
+
+ )
+}
+
+function ProfileRow() {
+ const { settings, updateSettings } = useSettings()
+ return (
+
+
+ Профиль
+
+ 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) => (
+
+ {item.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)}
+ {name.slice(index, index + needle.length)}
+ {name.slice(index + needle.length)}
+ >
+ )
+}
diff --git a/desktop/src/renderer/src/components/StatusBar.tsx b/desktop/src/renderer/src/components/StatusBar.tsx
new file mode 100644
index 0000000..e0dafa8
--- /dev/null
+++ b/desktop/src/renderer/src/components/StatusBar.tsx
@@ -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 (
+
+
+
+
+ {file?.name ?? 'документ'}
+ {dirty ? M : null}
+
+
+
+ UTF-8
+
+ {mode === 'notion' ? (
+ Блоки
+ ) : (
+
+ Ln {cursor.line}, Col {cursor.col}
+
+ )}
+ {stats.words.toLocaleString('ru-RU')} слов
+ {stats.tables} табл.
+ {stats.figures} рис.
+ {stats.formulas} форм.
+ {notice ? {notice} : null}
+
+
+
+ updateSettings({ zoom: Math.max(70, zoom - 10) })}>
+
+
+ {zoom}%
+ updateSettings({ zoom: Math.min(180, zoom + 10) })}>
+
+
+
+
+
+ )
+}
diff --git a/desktop/src/renderer/src/components/TitleBar.tsx b/desktop/src/renderer/src/components/TitleBar.tsx
new file mode 100644
index 0000000..0b761fc
--- /dev/null
+++ b/desktop/src/renderer/src/components/TitleBar.tsx
@@ -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): Promise {
+ try {
+ const result = await task()
+ setNotice(result.ok ? null : result.error)
+ } catch {
+ setNotice('не подключено')
+ }
+ }
+
+ const mac = window.frame.platform === 'darwin'
+
+ return (
+
+ )
+}
+
+function IconToggle({
+ on,
+ title,
+ icon,
+ onClick,
+}: {
+ on: boolean
+ title: string
+ icon: string
+ onClick: () => void
+}) {
+ return (
+
+
+
+ )
+}
+
+function MacControls() {
+ return (
+
+ void window.frame.close()} />
+ void window.frame.minimize()} />
+ void window.frame.toggleMaximize()}
+ />
+
+ )
+}
+
+function TrafficLight({ color, title, onClick }: { color: string; title: string; onClick: () => void }) {
+ return
+}
+
+function WinControls() {
+ return (
+
+ void window.frame.minimize()}>
+
+
+ void window.frame.toggleMaximize()}>
+
+
+ void window.frame.close()}>
+
+
+
+
+
+
+ )
+}
+
+function CaptionButton({
+ title,
+ onClick,
+ close = false,
+ children,
+}: {
+ title: string
+ onClick: () => void
+ close?: boolean
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/desktop/src/renderer/src/components/WordPreview.tsx b/desktop/src/renderer/src/components/WordPreview.tsx
new file mode 100644
index 0000000..45bb97c
--- /dev/null
+++ b/desktop/src/renderer/src/components/WordPreview.tsx
@@ -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 = {
+ narrow: '12mm',
+ normal: '20mm',
+ wide: '28mm',
+}
+
+const LABELS: Record = {
+ markdown: 'Предпросмотр',
+ svg: 'Рисунок',
+ yaml: 'Поля',
+ bib: 'Источники',
+ diagram: 'Схема',
+ text: 'Текст',
+}
+
+type WordPreviewProps = {
+ text: string
+ fileName: string
+ fileId: string
+ files: Record
+ onScroll: () => void
+}
+
+export const WordPreview = forwardRef(function WordPreview(
+ { text, fileName, fileId, files, onScroll },
+ ref,
+) {
+ const kind = previewKind(fileName, text)
+
+ return (
+
+
+ {LABELS[kind]}
+ {fileName ? {fileName} : null}
+
+
+ {kind === 'svg' ? : null}
+ {kind === 'yaml' ? : null}
+ {kind === 'bib' ? : null}
+ {kind === 'diagram' ? : null}
+ {kind === 'text' ? : null}
+ {kind === 'markdown' ? : null}
+
+
+ )
+})
+
+function Sheet({ children, mono = false }: { children: ReactNode; mono?: boolean }) {
+ const { settings } = useSettings()
+ return (
+
+ {children}
+
+ )
+}
+
+function SvgSheet({ text }: { text: string }) {
+ const svg = text.trim()
+ return (
+
+ {svg.startsWith('
+ ) : (
+ В файле нет рисунка SVG
+ )}
+
+ )
+}
+
+function YamlSheet({ text }: { text: string }) {
+ const rows = parseYamlPreview(text)
+ return (
+
+ {rows.length === 0 ? Пустой файл
: null}
+ {rows.map((row, index) =>
+ row.note ? (
+
+ {row.value}
+
+ ) : (
+
+ {row.key ? {row.key} : null}
+ {row.key && row.value ? : : null}
+ {row.value}
+
+ ),
+ )}
+
+ )
+}
+
+function BibSheet({ text }: { text: string }) {
+ const entries = parseBib(text)
+ return (
+
+ {entries.length === 0 ? В файле нет записей
: 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 (
+
+ {entry.type}
+ {title}
+ {author ? {author}
: null}
+
+ {entry.fields
+ .filter((field) => field.key !== 'title' && field.key !== 'author')
+ .map((field) => `${field.key}: ${field.value}`)
+ .join(' · ')}
+
+
+ )
+ })}
+
+ )
+}
+
+function TextSheet({ text }: { text: string }) {
+ return (
+
+ {text.trim() ? {text} : Пустой файл
}
+
+ )
+}
+
+function DiagramSheet({ text }: { text: string }) {
+ const model = parseDiagram(text)
+ return (
+
+
+
+ )
+}
+
+function DiagramView({ model, source }: { model: DiagramModel; source: string }) {
+ if (model.nodes.length === 0) {
+ return {source.trim() || 'Пустая схема'}
+ }
+ const layout = diagramLayout(model)
+ return (
+
+ {model.title ?
{model.title}
: null}
+ {layout === 'sequence' ?
: null}
+ {layout === 'entities' ?
: null}
+ {layout === 'blocks' ?
: null}
+
+ )
+}
+
+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 (
+
+
+ {columns.map((node) => (
+
+ {node.label}
+
+ ))}
+ {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 (
+
+ {edge.label} {to >= from ? '→' : '←'}
+
+ )
+ })}
+
+
+ )
+}
+
+function EntityDiagram({ model }: { model: DiagramModel }) {
+ return (
+
+
+ {model.nodes
+ .filter((node) => node.kind === 'entity')
+ .map((node) => (
+
+ {node.label}
+
+ {node.fields.map((field) => (
+
+ {field}
+
+ ))}
+
+
+ ))}
+
+
+
+ )
+}
+
+function BlockDiagram({ model }: { model: DiagramModel }) {
+ return (
+
+
+ {model.nodes.map((node) => (
+
+ ))}
+
+
+
+ )
+}
+
+function BlockCard({ node }: { node: DiagramNode }) {
+ const rounded = node.kind === 'usecase' || node.kind === 'person' || node.kind === 'actor'
+ return (
+
+
{kindLabel(node.kind)}
+
{node.label}
+ {node.detail ?
{node.detail}
: null}
+
+ )
+}
+
+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 (
+
+ {model.edges.map((edge, index) => (
+
+ {nodeName(model, edge.from)} → {nodeName(model, edge.to)}
+ {edge.label ? ` — ${edge.label}` : ''}
+
+ ))}
+
+ )
+}
+
+function MarkdownSheet({
+ text,
+ fileId,
+ files,
+}: {
+ text: string
+ fileId: string
+ files: Record
+}) {
+ const blocks = previewBlocks(text)
+ return (
+
+ {blocks.length === 0 ? Пустой документ
: null}
+ {blocks.map((block, index) => (
+
+ ))}
+
+ )
+}
+
+function MarkdownBlock({
+ block,
+ fileId,
+ files,
+}: {
+ block: PreviewBlock
+ fileId: string
+ files: Record
+}) {
+ if (block.type === 'h') {
+ const size = block.level === 1 ? '1.35em' : block.level === 2 ? '1.15em' : '1.05em'
+ return (
+
+
+
+ )
+ }
+ if (block.type === 'ul' || block.type === 'ol') {
+ const Tag = block.type === 'ul' ? 'ul' : 'ol'
+ return (
+
+ {block.items.map((item, itemIndex) => (
+
+
+
+ ))}
+
+ )
+ }
+ if (block.type === 'quote') {
+ return (
+
+
+
+ )
+ }
+ if (block.type === 'code' && isDiagramLang(block.lang)) {
+ return (
+
+
+
+ )
+ }
+ if (block.type === 'code') {
+ return {block.text}
+ }
+ if (block.type === 'table') {
+ return (
+
+
+ {block.rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+
+
+
+ ))}
+
+ ))}
+
+
+ )
+ }
+ if (block.type === 'formula') return {block.text}
+ if (block.type === 'caption') {
+ return (
+
+
+
+ )
+ }
+ if (block.type === 'figure') return
+ if (block.type === 'break') return
+ return (
+
+
+
+ )
+}
+
+function FigureBlock({
+ alt,
+ src,
+ fileId,
+ files,
+}: {
+ alt: string
+ src: string
+ fileId: string
+ files: Record
+}) {
+ const linked = files[resolveWorkspacePath(fileId, src)]
+ const svg = linked?.text.trimStart().startsWith('
+ {svg ? (
+
+ ) : (
+ рисунок
+ )}
+ {alt}
+
+ )
+}
+
+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 {chunk.slice(2, -2)}
+ if (chunk.startsWith('*') && chunk.endsWith('*') && chunk.length > 2) return {chunk.slice(1, -1)}
+ if (chunk.startsWith('`') && chunk.endsWith('`') && chunk.length > 2) {
+ return (
+
+ {chunk.slice(1, -1)}
+
+ )
+ }
+ if (chunk.startsWith('@')) {
+ return (
+
+ {chunk}
+
+ )
+ }
+ const link = /^\[([^\]]+)\]\([^)]+\)$/.exec(chunk)
+ if (link) {
+ return (
+
+ {link[1]}
+
+ )
+ }
+ return {chunk}
+ })
+}
diff --git a/desktop/src/renderer/src/css.d.ts b/desktop/src/renderer/src/css.d.ts
new file mode 100644
index 0000000..a839446
--- /dev/null
+++ b/desktop/src/renderer/src/css.d.ts
@@ -0,0 +1 @@
+declare module '*.css' {}
diff --git a/desktop/src/renderer/src/documents.ts b/desktop/src/renderer/src/documents.ts
new file mode 100644
index 0000000..73bb3a6
--- /dev/null
+++ b/desktop/src/renderer/src/documents.ts
@@ -0,0 +1,370 @@
+export type WorkspaceFile = {
+ id: string
+ name: string
+ dir: string[]
+ icon: string
+ tone: string
+ text: string
+ initial: string
+}
+
+export type InsertKind = 'intro' | 'table' | 'figure' | 'formula' | 'diagram' | 'ref' | 'break'
+
+export const ACTIVE_FILE_ID = 'mirea/coursework_report.md'
+
+const COURSEWORK = `# *СОДЕРЖАНИЕ
+[TOC]
+
+# *ВВЕДЕНИЕ
+
+В настоящей работе рассматривается задача построения информационной системы учёта. Цель работы — спроектировать архитектуру и реализовать прототип модуля обработки данных.
+
+# Анализ предметной области
+
+Предметная область связана с обработкой структурированных данных. Обзор подходов приведён в работах [1], [2].
+
+Структура системы показана на @Рисунок:arch.
+
+
+
+Сравнение вариантов реализации представлено в @Таблица:cmp.
+
+%cmp Сравнение подходов
+
+| Подход | Сложность | Гибкость |
+|--------|-----------|----------|
+| Монолит | низкая | низкая |
+| Модульный | средняя | высокая |
+| Микросервисы | высокая | высокая |
+
+Основные требования к системе:
+
+- поддержка импорта данных;
+- формирование отчётности;
+- разграничение прав доступа.
+
+Порядок разработки включает следующие этапы:
+
+1. анализ требований;
+2. проектирование архитектуры;
+3. реализация прототипа.
+
+# Проектирование
+
+Ключевое соотношение для оценки объёма данных задаётся формулой:
+
+%eq1
+$$
+V = N \\times S
+$$
+
+где $N$ — число записей, $S$ — средний размер записи. Использование зависимости приведено в (@Формула:eq1).
+
+Фрагмент реализации:
+
+%code1 Процедура расчёта объёма
+
+\`\`\`python
+def volume(n: int, s: float) -> float:
+ return n * s
+\`\`\`
+
+Пример вызова приведён в @Листинг:code1.
+
+Диаграмма контейнеров лежит в файле schemas/uml_c4.puml, модель данных — в schemas/er.puml.
+
+# *ЗАКЛЮЧЕНИЕ
+
+В работе выполнен анализ предметной области, спроектирована архитектура и реализован прототип модуля расчёта.
+
+# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
+
+[1]: Иванов И. И. Информационные системы. — М.: Наука, 2023. — 120 с.
+[2]: Петров П. П. Базы данных: учебник. — СПб.: Питер, 2022. — 200 с.
+[3]: ГОСТ 7.32-2017. Отчет о научно-исследовательской работе. — М., 2022.
+[4]: Сидоров С. С. Разработка на Python. — М.: ДМК Пресс, 2024. — 300 с.
+[5]: Козлов К. К. Анализ данных. — М.: Бином, 2022. — 150 с.
+[6]: Смирнова А. А. Проектирование ПО. — М.: Инфра-М, 2023. — 180 с.
+
+# *ПРИЛОЖЕНИЯ
+
+## Приложение А Исходный код модуля
+
+Дополнительный код модуля вынесен в приложение.
+`
+
+const EXAMPLE = `# *ВВЕДЕНИЕ
+
+Краткий пример отчёта. Его можно править отдельно от курсовой: у каждого файла свой текст.
+
+# 1 Постановка задачи
+
+Нужно описать модуль и сослаться на источник [1].
+
+%table1 Состав модуля
+
+| Часть | Назначение |
+|-------|------------|
+| Интерфейс | ввод данных |
+| Расчёт | обработка |
+
+# *ЗАКЛЮЧЕНИЕ
+
+Пример показывает разделы, таблицу и ссылку на источник.
+
+# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
+
+[1]: ГОСТ 7.32-2017. Отчет о научно-исследовательской работе. — М., 2022.
+`
+
+const SPEC = `# Черновик профиля отчёта.
+# Сборка DOCX этот файл не читает: ядро не подключено.
+# Поля совпадают с тем, что позже уйдёт в спецификацию профиля.
+
+profile: coursework
+document: Курсовая работа
+standard: ГОСТ 7.32
+language: ru
+
+institute: МИРЭА
+department: Кафедра
+discipline: Проектирование информационных систем
+supervisor: Иванов И. И.
+
+numbering:
+ figures: по разделам
+ tables: по разделам
+ formulas: сквозная для формул со ссылкой
+
+sections:
+ - "*СОДЕРЖАНИЕ"
+ - "*ВВЕДЕНИЕ"
+ - Анализ предметной области
+ - Проектирование
+ - "*ЗАКЛЮЧЕНИЕ"
+ - "*СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"
+ - "*ПРИЛОЖЕНИЯ"
+`
+
+const INFO = `# Данные титульного листа рядом с отчётом.
+# ФИО и группа задаются в настройках окна, не здесь.
+
+work_number: "0000"
+year: 2026
+institute: МИРЭА
+department: Кафедра
+discipline: Проектирование информационных систем
+teacher: Иванов И. И.
+theme: Информационная система учёта
+`
+
+const BIB = `@book{ivanov2023,
+ author = {Иванов, И. И.},
+ title = {Информационные системы},
+ publisher = {Наука},
+ address = {М.},
+ year = {2023},
+ pages = {120}
+}
+
+@book{petrov2022,
+ author = {Петров, П. П.},
+ title = {Базы данных},
+ publisher = {Питер},
+ address = {СПб.},
+ year = {2022},
+ pages = {200}
+}
+
+@standard{gost732,
+ title = {ГОСТ 7.32-2017. Отчет о научно-исследовательской работе},
+ address = {М.},
+ year = {2022}
+}
+`
+
+const C4 = `@startuml
+!include
+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 = `
+
+ Пользователь
+
+ Система учёта
+
+
+
+
+
+
+
+`
+
+const CONTAINER_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 {
+ return {
+ [ACTIVE_FILE_ID]: file(ACTIVE_FILE_ID, ['mirea'], 'coursework_report.md', 'description', 'text-primary', COURSEWORK),
+ 'mirea/mirea_spec.yaml': file('mirea/mirea_spec.yaml', ['mirea'], 'mirea_spec.yaml', 'data_object', 'text-secondary', SPEC),
+ 'mirea/info_conv.yaml': file('mirea/info_conv.yaml', ['mirea'], 'info_conv.yaml', 'data_object', 'text-secondary', INFO),
+ 'mirea/bibliography.bib': file('mirea/bibliography.bib', ['mirea'], 'bibliography.bib', 'menu_book', 'text-tertiary', BIB),
+ 'mirea/schemas/uml_c4.puml': file(
+ 'mirea/schemas/uml_c4.puml',
+ ['mirea', 'schemas'],
+ 'uml_c4.puml',
+ 'account_tree',
+ 'text-primary',
+ C4,
+ ),
+ 'mirea/schemas/er.puml': file('mirea/schemas/er.puml', ['mirea', 'schemas'], 'er.puml', 'account_tree', 'text-primary', ER),
+ 'mirea/schemas/sequence.puml': file(
+ 'mirea/schemas/sequence.puml',
+ ['mirea', 'schemas'],
+ 'sequence.puml',
+ 'account_tree',
+ 'text-primary',
+ SEQUENCE,
+ ),
+ 'mirea/figures/context.svg': file(
+ 'mirea/figures/context.svg',
+ ['mirea', 'figures'],
+ 'context.svg',
+ 'image',
+ 'text-primary-fixed-dim',
+ CONTEXT_SVG,
+ ),
+ 'mirea/figures/container.svg': file(
+ 'mirea/figures/container.svg',
+ ['mirea', 'figures'],
+ 'container.svg',
+ 'image',
+ 'text-primary-fixed-dim',
+ CONTAINER_SVG,
+ ),
+ 'example.md': file('example.md', [], 'example.md', 'description', 'text-primary', EXAMPLE),
+ }
+}
+
+export function folderIds(dir: string[]): string[] {
+ const ids: string[] = []
+ for (let index = 0; index < dir.length; index += 1) {
+ ids.push(`dir:${dir.slice(0, index + 1).join('/')}`)
+ }
+ return ids
+}
+
+export function makeSnippet(kind: InsertKind, n: number): string {
+ if (kind === 'intro') return '# *ВВЕДЕНИЕ\n\n'
+ if (kind === 'table') {
+ return `%table${n} Название таблицы\n\n| Показатель | Значение |\n|---|---|\n| | |\n\n`
+ }
+ if (kind === 'figure') return `\n\n`
+ if (kind === 'formula') return `%eq${n}\n$$\nE = mc^2\n$$\n\n`
+ if (kind === 'diagram') {
+ return `%scheme${n} Название схемы\n\n\`\`\`plantuml\n@startuml\nactor Пользователь\nПользователь --> (Система)\n@enduml\n\`\`\`\n\n`
+ }
+ if (kind === 'ref') return '[1]'
+ return '---\n\n'
+}
+
+export function insertAtCursor(
+ source: string,
+ line: number,
+ col: number,
+ snippet: string,
+): { text: string; line: number } {
+ const lines = source.split('\n')
+ const index = Math.min(Math.max(lines.length - 1, 0), Math.max(0, line - 1))
+ const current = lines[index] ?? ''
+ const at = Math.min(current.length, Math.max(0, col - 1))
+ const block = snippet.includes('\n')
+ const chunk = `${block && current.slice(0, at).trim() !== '' ? '\n' : ''}${snippet}`
+ lines[index] = current.slice(0, at) + chunk + current.slice(at)
+ return { text: lines.join('\n'), line: index + chunk.split('\n').length }
+}
+
+export function documentStats(text: string): { words: number; tables: number; figures: number; formulas: number } {
+ const lines = text.split('\n')
+ let tables = 0
+ let figures = 0
+ let formulas = 0
+ let inMath = false
+ let inFence = false
+ for (const line of lines) {
+ if (/^(```|~~~)/.test(line)) {
+ if (!inFence && /^(```|~~~)(uml|plantuml|mermaid|mmd|idef0|dfd|c4|usecase)\b/i.test(line)) figures += 1
+ inFence = !inFence
+ continue
+ }
+ if (line.trim() === '$$') {
+ if (!inMath) formulas += 1
+ inMath = !inMath
+ continue
+ }
+ const trimmed = line.trim()
+ if (trimmed.includes('|') && trimmed.includes('-') && /^[\s|:-]+$/.test(trimmed)) tables += 1
+ if (/!\[[^\]]*]\(/.test(line)) figures += 1
+ }
+ const words = text.trim() ? text.trim().split(/\s+/).length : 0
+ return { words, tables, figures, formulas }
+}
diff --git a/desktop/src/renderer/src/editor/NotionEditor.tsx b/desktop/src/renderer/src/editor/NotionEditor.tsx
new file mode 100644
index 0000000..c91e671
--- /dev/null
+++ b/desktop/src/renderer/src/editor/NotionEditor.tsx
@@ -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(() => 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 (
+
+ )
+}
+
+function BlockStack({ blocks, onChange }: { blocks: Block[]; onChange: (blocks: Block[]) => void }) {
+ const [menu, setMenu] = useState(null)
+
+ function update(id: string, patch: Partial) {
+ 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 (
+ setMenu(null)}>
+ {blocks.map((block) => (
+ 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))}
+ />
+ ))}
+ {
+ event.stopPropagation()
+ const last = blocks[blocks.length - 1]
+ insertAfter(last.id, 'paragraph')
+ }}
+ >
+
+ Добавить блок
+
+
+ )
+}
+
+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 (
+ event.preventDefault()}
+ onDrop={(event) => {
+ event.preventDefault()
+ const fromId = event.dataTransfer.getData('text/plain')
+ if (fromId) onDrop(fromId)
+ }}
+ >
+
+ {
+ event.stopPropagation()
+ onOpenMenu('insert')
+ }}
+ >
+
+
+ {
+ event.dataTransfer.setData('text/plain', block.id)
+ event.dataTransfer.effectAllowed = 'move'
+ }}
+ >
+
+
+
+
+
+
{
+ event.stopPropagation()
+ onOpenMenu('convert')
+ }}
+ >
+ {kind.chip}
+
+
+ {block.type === 'bullet' ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {menu ? (
+
event.stopPropagation()}
+ >
+
+ {menu === 'insert' ? 'Новый блок' : 'Превратить в'}
+
+ {BLOCK_KINDS.map((item) => (
+
onChoose(item.type)}
+ >
+
+
+ {item.label}
+ {item.hint}
+
+
+ ))}
+
+ ) : null}
+
+ )
+}
+
+function BlockText({
+ block,
+ onText,
+ onEnter,
+ onDelete,
+}: {
+ block: Block
+ onText: (text: string) => void
+ onEnter: () => void
+ onDelete: () => void
+}) {
+ const ref = useRef(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 (
+
+ {block.text === '' ? (
+
+ {placeholder(block.type)}
+
+ ) : null}
+
{
+ 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()
+ }
+ }}
+ />
+
+ )
+}
+
+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'
+ }
+}
diff --git a/desktop/src/renderer/src/editor/SourceEditor.tsx b/desktop/src/renderer/src/editor/SourceEditor.tsx
new file mode 100644
index 0000000..84b5b7b
--- /dev/null
+++ b/desktop/src/renderer/src/editor/SourceEditor.tsx
@@ -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
(null)
+ const viewRef = useRef(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
+}
diff --git a/desktop/src/renderer/src/editor/blocks.ts b/desktop/src/renderer/src/editor/blocks.ts
new file mode 100644
index 0000000..dcd55e7
--- /dev/null
+++ b/desktop/src/renderer/src/editor/blocks.ts
@@ -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
+}
diff --git a/desktop/src/renderer/src/env.d.ts b/desktop/src/renderer/src/env.d.ts
new file mode 100644
index 0000000..8196079
--- /dev/null
+++ b/desktop/src/renderer/src/env.d.ts
@@ -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 {}
diff --git a/desktop/src/renderer/src/explorer.ts b/desktop/src/renderer/src/explorer.ts
new file mode 100644
index 0000000..5ade19d
--- /dev/null
+++ b/desktop/src/renderer/src/explorer.ts
@@ -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 = new Map(),
+): Map {
+ 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, 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
+}
diff --git a/desktop/src/renderer/src/helpTopics.ts b/desktop/src/renderer/src/helpTopics.ts
new file mode 100644
index 0000000..332f8b4
--- /dev/null
+++ b/desktop/src/renderer/src/helpTopics.ts
@@ -0,0 +1,38 @@
+export type HelpTopic = {
+ id: string
+ title: string
+ paragraphs: string[]
+}
+
+export const HELP_TOPICS: HelpTopic[] = [
+ {
+ id: 'syntax',
+ title: 'Синтаксис отчёта',
+ paragraphs: [
+ 'Текст отчёта — Markdown. Заголовки начинаются с #. Звёздочка сразу после решёток снимает номер раздела: # *ВВЕДЕНИЕ, # *ЗАКЛЮЧЕНИЕ, # *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ.',
+ 'После # *СОДЕРЖАНИЕ ставится отдельная строка [TOC]. Точку в конце заголовка не ставят.',
+ 'Списки, жирный и курсив работают как в обычном Markdown. Источник в тексте — [1] или [2, 3]. Во введении и заключении таких ссылок быть не должно.',
+ 'Строка --- в этом редакторе видна как разрыв. Сборка DOCX её обработает, когда будет подключено ядро.',
+ ],
+ },
+ {
+ id: 'objects',
+ title: 'Таблицы, рисунки, формулы',
+ paragraphs: [
+ 'Таблица: строка %id Название, затем таблица Markdown. В тексте ссылка пишется как @Таблица:id.',
+ 'Рисунок-файл: . В тексте — @Рисунок:id. Слово «Рисунок» не сокращают.',
+ 'Формула нумеруется, только если на неё есть ссылка. Блок: %id, затем $$ … $$, в тексте (@Формула:id).',
+ 'Листинг: %id Название и следом ограда с языком. Ссылка — @Листинг:id.',
+ 'Кнопки над редактором вставляют эти заготовки в текущую строку.',
+ ],
+ },
+ {
+ id: 'diagrams',
+ title: 'Схемы',
+ paragraphs: [
+ 'Исходник схемы хранится в .puml или в ограде ```plantuml прямо в отчёте. Перед оградой — %id Название, тогда схема станет рисунком.',
+ 'Языки ограды: plantuml, uml, mermaid, idef0, dfd. Картинку из схемы соберёт ядро. Пока в окне виден исходник, а файлы .svg открываются картинкой.',
+ 'Готовые рисунки лежат в figures. На них можно сослаться из отчёта обычной картинкой Markdown.',
+ ],
+ },
+]
diff --git a/desktop/src/renderer/src/main.tsx b/desktop/src/renderer/src/main.tsx
new file mode 100644
index 0000000..77a5c1c
--- /dev/null
+++ b/desktop/src/renderer/src/main.tsx
@@ -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(
+
+
+ ,
+)
diff --git a/desktop/src/renderer/src/preview.ts b/desktop/src/renderer/src/preview.ts
new file mode 100644
index 0000000..a3c73af
--- /dev/null
+++ b/desktop/src/renderer/src/preview.ts
@@ -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
+}
diff --git a/desktop/src/renderer/src/previewFile.ts b/desktop/src/renderer/src/previewFile.ts
new file mode 100644
index 0000000..c10f86c
--- /dev/null
+++ b/desktop/src/renderer/src/previewFile.ts
@@ -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(' 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()
+ 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'
+}
diff --git a/desktop/src/renderer/src/session.tsx b/desktop/src/renderer/src/session.tsx
new file mode 100644
index 0000000..307d021
--- /dev/null
+++ b/desktop/src/renderer/src/session.tsx
@@ -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
+ 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(null)
+
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const seeded = useRef(seedFiles())
+ const [files, setFiles] = useState>(seeded.current)
+ const [folders, setFolders] = useState([])
+ const [activeId, setActiveId] = useState(ACTIVE_FILE_ID)
+ const [text, setTextState] = useState(seeded.current[ACTIVE_FILE_ID]?.text ?? '')
+ const [mode, setMode] = useState('code')
+ const [cursor, setCursor] = useState({ line: 1, col: 1 })
+ const [notice, setNotice] = useState(null)
+ const [focusLine, setFocusLine] = useState(1)
+ const [focusToken, setFocusToken] = useState(0)
+ const [activity, setActivity] = useState([])
+ const [editorCommand, setEditorCommand] = useState('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 {children}
+}
+
+export function useSession(): SessionValue {
+ const value = useContext(SessionContext)
+ if (!value) throw new Error('useSession вне SessionProvider')
+ return value
+}
diff --git a/desktop/src/renderer/src/settings.tsx b/desktop/src/renderer/src/settings.tsx
new file mode 100644
index 0000000..e5f9e80
--- /dev/null
+++ b/desktop/src/renderer/src/settings.tsx
@@ -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(DOC_PROFILES.map((item) => item.id))
+
+function oneOf(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) : {}
+ 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) => void
+ resetEditorSettings: () => void
+ resetPreviewSettings: () => void
+ open: boolean
+ openSettings: () => void
+ toggleOpen: () => void
+ profile: (typeof DOC_PROFILES)[number]
+}
+
+const SettingsContext = createContext(null)
+
+export function SettingsProvider({ children }: { children: ReactNode }) {
+ const [settings, setSettings] = useState(readSettings)
+ const [open, setOpen] = useState(false)
+
+ const updateSettings = useCallback((patch: Partial) => {
+ 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 {children}
+}
+
+export function useSettings(): SettingsValue {
+ const value = useContext(SettingsContext)
+ if (!value) throw new Error('useSettings вне SettingsProvider')
+ return value
+}
diff --git a/desktop/src/renderer/src/styles.css b/desktop/src/renderer/src/styles.css
new file mode 100644
index 0000000..16f508a
--- /dev/null
+++ b/desktop/src/renderer/src/styles.css
@@ -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%;
+}
diff --git a/desktop/src/renderer/src/tree.ts b/desktop/src/renderer/src/tree.ts
new file mode 100644
index 0000000..9af471e
--- /dev/null
+++ b/desktop/src/renderer/src/tree.ts
@@ -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; 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, [])
+}
diff --git a/desktop/src/shared/chrome.ts b/desktop/src/shared/chrome.ts
new file mode 100644
index 0000000..3d7f4ad
--- /dev/null
+++ b/desktop/src/shared/chrome.ts
@@ -0,0 +1,8 @@
+/** Кнопки рамки окна. К конвейеру md2gost не относятся. */
+export interface ChromeApi {
+ /** darwin — кнопки слева, остальные системы — справа. */
+ platform: string
+ minimize(): Promise
+ toggleMaximize(): Promise
+ close(): Promise
+}
diff --git a/desktop/src/shared/host.ts b/desktop/src/shared/host.ts
new file mode 100644
index 0000000..0b6e957
--- /dev/null
+++ b/desktop/src/shared/host.ts
@@ -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
+ save(text: string): Promise
+ check(text: string): Promise
+ build(text: string): Promise
+ edit(files: EditorFile[], activeId: string): Promise
+}
diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json
new file mode 100644
index 0000000..155ebaa
--- /dev/null
+++ b/desktop/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.web.json" }
+ ]
+}
diff --git a/desktop/tsconfig.node.json b/desktop/tsconfig.node.json
new file mode 100644
index 0000000..81e7c67
--- /dev/null
+++ b/desktop/tsconfig.node.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "types": ["node", "electron-vite/node"],
+ "lib": ["ES2022"]
+ },
+ "include": [
+ "electron.vite.config.ts",
+ "src/main/**/*.ts",
+ "src/preload/**/*.ts",
+ "src/shared/**/*.ts"
+ ]
+}
diff --git a/desktop/tsconfig.web.json b/desktop/tsconfig.web.json
new file mode 100644
index 0000000..73028ec
--- /dev/null
+++ b/desktop/tsconfig.web.json
@@ -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"]
+}
diff --git a/docs/markdown.md b/docs/markdown.md
index d349547..6db9b26 100644
--- a/docs/markdown.md
+++ b/docs/markdown.md
@@ -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` — альбомная страница под широкий рисунок/таблицу
diff --git a/docs/schemes.md b/docs/schemes.md
index 694a3e7..b360e1f 100644
--- a/docs/schemes.md
+++ b/docs/schemes.md
@@ -33,7 +33,8 @@ Rel(user, app, "логин")
| `` ```uml- `` / `` ``` `` | Схема из `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
diff --git a/md2gost/checker/__init__.py b/md2gost/checker/__init__.py
index 2164e4b..95144d8 100644
--- a/md2gost/checker/__init__.py
+++ b/md2gost/checker/__init__.py
@@ -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,
))
diff --git a/md2gost/dfd_gs.py b/md2gost/dfd_gs.py
new file mode 100644
index 0000000..3659fa9
--- /dev/null
+++ b/md2gost/dfd_gs.py
@@ -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'',
+ ' ',
+ ]
+ for op in self.ops:
+ kind = op[0]
+ if kind == "line":
+ _, x1, y1, x2, y2, w = op
+ parts.append(
+ f' '
+ )
+ elif kind == "polyline":
+ _, pts, w = op
+ d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
+ parts.append(
+ f' '
+ )
+ elif kind == "rect":
+ _, x, y, w, h, lw, fill, outline = op
+ parts.append(
+ f' '
+ )
+ elif kind == "roundrect":
+ _, x, y, w, h, radius, lw, fill, outline = op
+ parts.append(
+ f' '
+ )
+ elif kind == "polygon":
+ _, pts, fill = op
+ d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
+ parts.append(f' ')
+ 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'{esc(text)} '
+ )
+ elif kind == "text_rot":
+ _, x, y, text, size, bold, angle = op
+ weight = "700" if bold else "400"
+ parts.append(
+ f'{esc(text)} '
+ )
+ parts.append(" ")
+ 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 превью и хеш
+"""
diff --git a/md2gost/diagram_renderer.py b/md2gost/diagram_renderer.py
index 391bf9e..42fe107 100644
--- a/md2gost/diagram_renderer.py
+++ b/md2gost/diagram_renderer.py
@@ -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
diff --git a/md2gost/diagram_schemes.py b/md2gost/diagram_schemes.py
index 50064be..5eae79d 100644
--- a/md2gost/diagram_schemes.py
+++ b/md2gost/diagram_schemes.py
@@ -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",
}
diff --git a/md2gost/diagrams/schemes.json b/md2gost/diagrams/schemes.json
index bd3578e..858fba3 100644
--- a/md2gost/diagrams/schemes.json
+++ b/md2gost/diagrams/schemes.json
@@ -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 \n!theme archimate-standard from \n",
+ "prefix": "@startuml\n!include \n",
"postfix": "\n@enduml"
}
}
diff --git a/md2gost/extended_markdown/caption.py b/md2gost/extended_markdown/caption.py
index 2604500..c0c8570 100644
--- a/md2gost/extended_markdown/caption.py
+++ b/md2gost/extended_markdown/caption.py
@@ -45,4 +45,4 @@ class Caption(BlockElement):
def parse(cls, source: Source) -> Match[str] | None:
m = source.match
source.consume()
- return m
+ return m
\ No newline at end of file
diff --git a/md2gost/extended_markdown/table.py b/md2gost/extended_markdown/table.py
index b1b5ce9..103151c 100644
--- a/md2gost/extended_markdown/table.py
+++ b/md2gost/extended_markdown/table.py
@@ -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()
diff --git a/md2gost/gui.py b/md2gost/gui.py
index 09ec7b3..465e415 100644
--- a/md2gost/gui.py
+++ b/md2gost/gui.py
@@ -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Перезаписать?"
diff --git a/md2gost/help_content.py b/md2gost/help_content.py
index 48d9d97..937064a 100644
--- a/md2gost/help_content.py
+++ b/md2gost/help_content.py
@@ -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- / из файла схем (c4, usecase, archimate, …).
+Языки: uml, plantuml, mermaid / mmd, idef0, dfd / data-flow-diagram, dfd-gs / gane-sarson, или uml- / из файла схем (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.
Пример
diff --git a/md2gost/office/detect.py b/md2gost/office/detect.py
index 034a341..3f3b944 100644
--- a/md2gost/office/detect.py
+++ b/md2gost/office/detect.py
@@ -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)
diff --git a/md2gost/office/dispatch.py b/md2gost/office/dispatch.py
index 78a58d5..c3e4a2e 100644
--- a/md2gost/office/dispatch.py
+++ b/md2gost/office/dispatch.py
@@ -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(
diff --git a/md2gost/office/open_doc.py b/md2gost/office/open_doc.py
index a1abf33..b32289c 100644
--- a/md2gost/office/open_doc.py
+++ b/md2gost/office/open_doc.py
@@ -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:
diff --git a/md2gost/office/uno_fix.py b/md2gost/office/uno_fix.py
index 7fae509..25341ed 100644
--- a/md2gost/office/uno_fix.py
+++ b/md2gost/office/uno_fix.py
@@ -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 ").
+ 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:
diff --git a/md2gost/office/uno_helpers.py b/md2gost/office/uno_helpers.py
index bb95312..f59b6a9 100644
--- a/md2gost/office/uno_helpers.py
+++ b/md2gost/office/uno_helpers.py
@@ -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)
diff --git a/md2gost/office/uno_worker.py b/md2gost/office/uno_worker.py
index 148552d..0103851 100644
--- a/md2gost/office/uno_worker.py
+++ b/md2gost/office/uno_worker.py
@@ -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(
diff --git a/md2gost/page_fill_check.py b/md2gost/page_fill_check.py
index 0708eab..f986824 100644
--- a/md2gost/page_fill_check.py
+++ b/md2gost/page_fill_check.py
@@ -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)
diff --git a/md2gost/pipeline.py b/md2gost/pipeline.py
index ea39327..8da63b3 100644
--- a/md2gost/pipeline.py
+++ b/md2gost/pipeline.py
@@ -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),
diff --git a/md2gost/renderable/image.py b/md2gost/renderable/image.py
index 50540f8..77572ad 100644
--- a/md2gost/renderable/image.py
+++ b/md2gost/renderable/image.py
@@ -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)))
diff --git a/md2gost/renderable/list.py b/md2gost/renderable/list.py
index 7706873..bfb0d46 100644
--- a/md2gost/renderable/list.py
+++ b/md2gost/renderable/list.py
@@ -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]:
diff --git a/md2gost/renderer.py b/md2gost/renderer.py
index 0374466..90ebaa8 100644
--- a/md2gost/renderer.py
+++ b/md2gost/renderer.py
@@ -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):
diff --git a/md2gost/word_fix.py b/md2gost/word_fix.py
index 2f0824d..0aba16a 100644
--- a/md2gost/word_fix.py
+++ b/md2gost/word_fix.py
@@ -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)
diff --git a/tests/test_dfd_gs.py b/tests/test_dfd_gs.py
new file mode 100644
index 0000000..95fb6da
--- /dev/null
+++ b/tests/test_dfd_gs.py
@@ -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(" 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
diff --git a/tests/test_office.py b/tests/test_office.py
index 5ca8d9b..5388992 100644
--- a/tests/test_office.py
+++ b/tests/test_office.py
@@ -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")
diff --git a/tests/test_table_merge.py b/tests/test_table_merge.py
index 63b70a0..9908bbe 100644
--- a/tests/test_table_merge.py
+++ b/tests/test_table_merge.py
@@ -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():
diff --git a/tests/test_word_fix.py b/tests/test_word_fix.py
index cd7794f..f9bb782 100644
--- a/tests/test_word_fix.py
+++ b/tests/test_word_fix.py
@@ -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):