Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a523e1f542 |
-11
@@ -1,11 +0,0 @@
|
||||
/link/
|
||||
/link
|
||||
|
||||
# DocumentAutomation generated files and local source data
|
||||
DocumentAutomation/**/bin/
|
||||
DocumentAutomation/**/obj/
|
||||
DocumentAutomation/.vs/
|
||||
DocumentAutomation/samples/**/*.docx
|
||||
DocumentAutomation/samples/**/*.pdf
|
||||
DocumentAutomation/samples/**/*.json
|
||||
DocumentAutomation/**/*.csproj.user
|
||||
@@ -0,0 +1,129 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Назначение проекта
|
||||
|
||||
Этот репозиторий содержит аудит, архитектуру, исходный код, тесты, runtime
|
||||
evidence и документацию миграции корпоративной экосистемы Autodesk AutoCAD
|
||||
Plant 3D 2019 на Autodesk AutoCAD Plant 3D 2026.
|
||||
|
||||
Конечная цель — функционально эквивалентная рабочая схема Plant 3D 2026:
|
||||
корпоративный Launcher, локальная доставка и активация пакетов, managed
|
||||
плагины, команды, Ribbon, Plant project integration, DataLinks, Ortho,
|
||||
аннотации, обновление, диагностика и rollback. Миграция не считается
|
||||
завершённой после успешной компиляции: необходима runtime-проверка реальных
|
||||
сценариев в Plant 3D 2026.
|
||||
|
||||
## Связанные репозитории
|
||||
|
||||
- основной Autodesk-репозиторий: `C:\dev\autodesk`;
|
||||
- Launcher Next: `C:\dev\launcher-next`;
|
||||
- legacy launcher (read-only): `C:\dev\launcher\git`, `C:\dev\p_serv_starter`;
|
||||
- legacy P_Rec_Annot (read-only): `C:\dev\autodesk\link\P_Rec_Annot\v1.0.4\repo`.
|
||||
|
||||
Legacy и deployed-файлы нельзя изменять без отдельного прямого задания.
|
||||
|
||||
## Целевая платформа и принцип миграции
|
||||
|
||||
- Autodesk AutoCAD Plant 3D 2026, series R25.1, Windows x64, .NET 8;
|
||||
- использовать реальные Autodesk 2026 managed assemblies;
|
||||
- managed package загружается локально, не напрямую с UNC;
|
||||
- Autodesk DLL нельзя коммитить, помещать в package/fixture или публиковать.
|
||||
|
||||
Переносится функциональное поведение, а не историческая структура файлов. Не
|
||||
воспроизводить без отдельного доказательства 20 последовательных `NETLOAD`,
|
||||
legacy LISP bootstrap, `OrthoLoader.dll`, загрузку с сети или прежние
|
||||
workaround. Предпочтительная цепочка:
|
||||
|
||||
`Launcher Next → versioned local package → один managed entry assembly → internal dependencies → commands and Ribbon`.
|
||||
|
||||
## Статус legacy-команд
|
||||
|
||||
Ни одна активная команда Plant 3D 2019 не исчезает молча. Для каждой команды
|
||||
фиксируется ровно один статус: `IMPLEMENTED`, `RUNTIME_VERIFIED`,
|
||||
`REPLACED_WITH_NATIVE`, `RETIRED_APPROVED` или `BLOCKED_WITH_REASON`.
|
||||
Aliases сохраняются, пока их удаление не утверждено отдельно.
|
||||
|
||||
## Runtime уровни
|
||||
|
||||
`Smoke` проверяет assembly load, регистрацию команд, Plant context, Ribbon,
|
||||
отсутствие необработанных исключений и отсутствие записи в DWG.
|
||||
|
||||
`DryRun` проверяет реальные Plant data, Ortho/sheet/view definitions, DataLinks,
|
||||
Plant objects, детерминированный план и отсутствие записи в DWG/project DB.
|
||||
|
||||
`Apply` разрешается только отдельным явным заданием: write guard, ограниченная
|
||||
DWG transaction, тестовая копия проекта, baseline, rollback/UNDO, duplicate
|
||||
protection и runtime evidence обязательны. Apply нельзя включать только
|
||||
локальной конфигурацией.
|
||||
|
||||
## Runtime важнее предположений
|
||||
|
||||
Build и unit tests не доказывают Autodesk integration. Для
|
||||
`RUNTIME_VERIFIED` необходимы фактические результаты: команда, module path,
|
||||
DBMOD, Ribbon, DataLinks, Ortho status, telemetry и состояние файлов после
|
||||
закрытия. Не выдумывать UI evidence: использовать `null`, `NOT VERIFIED` или
|
||||
`BLOCKED WITH REASON`.
|
||||
|
||||
На тестовой машине реальный Plant 3D запуск разрешён, если он входит в задачу
|
||||
и есть test branch, локальный fixture, пройденные build/tests, rollback и нет
|
||||
production deploy. Не создавать искусственный review-gate перед Process.Start.
|
||||
Остановка допустима лишь после завершения доступной части работы и при
|
||||
конкретном внешнем blocker: установка, ОС, права, Autodesk runtime, fixture или
|
||||
необходимость production-данных.
|
||||
|
||||
## Test fixtures и отсутствие записи
|
||||
|
||||
Fixtures создаются вне Git, например `<TEST_FIXTURE_ROOT>`. Ortho fixture —
|
||||
настоящий Plant project с Pipe, DataLinks row, line group, Ortho drawing,
|
||||
Ortho viewport и source-model connection; обычный AutoCAD viewport не годится.
|
||||
Production DWG/project использовать нельзя без прямого разрешения.
|
||||
|
||||
Нельзя коммитить DWG, DCF, Project.xml с runtime-data, project DB, Ortho
|
||||
generated files или реальные engineering values.
|
||||
|
||||
Для Smoke/DryRun проверять DBMOD до/после, SHA-256 содержательных project
|
||||
files, отсутствие непредусмотренных transactions/DCF/project-metadata writes.
|
||||
`LastWriteTime` недостаточен. Отделять служебные Plant cache/session files от
|
||||
содержательных данных.
|
||||
|
||||
## Ribbon, механика и evidence
|
||||
|
||||
Ribbon — часть продукта: проверять tab, panel, button, command binding, stable
|
||||
IDs, отсутствие дубликатов при документах/workspace/reinitialization. Команда
|
||||
может быть отдельно рабочей, но plugin slice не завершён без Ribbon evidence
|
||||
или отдельного UI defect.
|
||||
|
||||
Документировать каждую runtime-цепочку: component/project/class/method,
|
||||
input/output, Autodesk transaction mode, writes, telemetry, failure code и
|
||||
rollback action. Документация соответствует фактическому коду.
|
||||
|
||||
Evidence содержит только проверенные значения. Не добавлять username, permit,
|
||||
полный environment, real project paths/line numbers/ObjectId/RowId/properties,
|
||||
полные logs, local manifest или startup scripts. Путь fixture заменять на
|
||||
`<TEST_FIXTURE_ROOT>`.
|
||||
|
||||
## Warnings и Git
|
||||
|
||||
Известный `MSB3277` WindowsBase 4.0.0.0/8.0.0.0 допустим только при полном
|
||||
совпадении с ранее зафиксированным warning. Новые warnings исследовать; не
|
||||
подавлять `NoWarn` без причины.
|
||||
|
||||
Запрещены force push, amend/rebase опубликованной истории, push binaries,
|
||||
изменение неизвестного remote и production deployment из test branch. Перед
|
||||
commit выполнять:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
git status --short
|
||||
git diff --cached --check
|
||||
```
|
||||
|
||||
Не смешивать Launcher defect, plugin defect, runtime evidence и unrelated
|
||||
formatting в одном commit.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Срез завершён, когда реализованы код, build/tests, валидный package и требуемый
|
||||
runtime-сценарий; записаны фактические результаты; выполнен rollback; fixture и
|
||||
production изолированы; механика документирована; ветка опубликована обычным
|
||||
push.
|
||||
@@ -1,9 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,206 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentAutomation.Shared", "src\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj", "{3D48D7EE-4C18-47A8-9F8F-E296491C0463}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.App", "src\DocxWorkbench.App\DocxWorkbench.App.csproj", "{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.Core", "src\DocxWorkbench.Core\DocxWorkbench.Core.csproj", "{5D7A031E-06AE-4725-89D8-C4EAD75700B1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.OpenXml", "src\DocxWorkbench.OpenXml\DocxWorkbench.OpenXml.csproj", "{6560936F-10A6-406E-8957-24E577A3F7EE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.WordInterop", "src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj", "{3349BB3A-001A-480D-9867-CF7199BDA04B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.App", "src\StandardsWorkbench.App\StandardsWorkbench.App.csproj", "{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.Core", "src\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj", "{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.Pdf", "src\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj", "{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.Ollama", "src\StandardsWorkbench.Ollama\StandardsWorkbench.Ollama.csproj", "{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentAutomation.IntegrationTests", "tests\DocumentAutomation.IntegrationTests\DocumentAutomation.IntegrationTests.csproj", "{6D7ED423-F943-4DF0-8D18-F69C127F3D67}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.Tests", "tests\DocxWorkbench.Tests\DocxWorkbench.Tests.csproj", "{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.Tests", "tests\StandardsWorkbench.Tests\StandardsWorkbench.Tests.csproj", "{6E533AAB-42E6-4C60-AE06-A89F2B283187}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|x86.Build.0 = Release|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{3D48D7EE-4C18-47A8-9F8F-E296491C0463} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{5D7A031E-06AE-4725-89D8-C4EAD75700B1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{6560936F-10A6-406E-8957-24E577A3F7EE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{3349BB3A-001A-480D-9867-CF7199BDA04B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{6D7ED423-F943-4DF0-8D18-F69C127F3D67} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{6E533AAB-42E6-4C60-AE06-A89F2B283187} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,15 +0,0 @@
|
||||
# DocumentAutomation
|
||||
|
||||
Первый локальный прототип состоит из независимых `DOCX Workbench` и `Standards Workbench` на .NET 8 Windows Forms.
|
||||
|
||||
DOCX Workbench анализирует DOCX через Open XML, формирует документ через локальный Microsoft Word COM, сохраняет шаблонные секции/колонтитулы, обновляет поля и экспортирует PDF. Standards Workbench импортирует PDF, сохраняет checksum, извлекает страницы и кандидатные пункты, обращается к Ollama только за предложением candidate-rule и экспортирует переносимый профиль.
|
||||
|
||||
## Ограничения MVP
|
||||
|
||||
- OCR только обозначен интерфейсом; страницы без текстового слоя получают статус `OCR_REQUIRED`.
|
||||
- Автоматическое утверждение нормативных правил запрещено.
|
||||
- Word COM требует установленного Microsoft Word и запускается только интерактивно, не как service.
|
||||
- Реальный `156-титул-ПД.docx` и нормативные PDF не добавлялись в Git.
|
||||
- PDF preview использует локальный Word ExportAsFixedFormat.
|
||||
|
||||
Подробности: `docs/architecture.md`, `docs/docx-template-contract.md`, `docs/word-com-lifecycle.md`, `docs/standards-rule-model.md`.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Architecture
|
||||
|
||||
`DocxWorkbench.App` and `StandardsWorkbench.App` are separate WinForms applications. They share only DTOs in `DocumentAutomation.Shared`.
|
||||
|
||||
DOCX flow: GUI -> `DocxWorkbench.Core` contracts -> `DocxWorkbench.OpenXml` read-only inspection or `DocxWorkbench.WordInterop` sequential Word COM queue -> DOCX/PDF and technical JSON log.
|
||||
|
||||
Standards flow: GUI -> `StandardsWorkbench.Pdf` page/clause extraction and `StandardsWorkbench.Ollama` candidate-only suggestions -> manually approved portable profile -> `StandardsWorkbench.Core.RulesEngine`.
|
||||
|
||||
COM is isolated from Core and UI. Standards rules are not hardcoded into document generation.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Development
|
||||
|
||||
```powershell
|
||||
dotnet restore .\DocumentAutomation\DocumentAutomation.sln
|
||||
dotnet build .\DocumentAutomation\DocumentAutomation.sln --configuration Release
|
||||
dotnet test .\DocumentAutomation\DocumentAutomation.sln --configuration Release
|
||||
```
|
||||
|
||||
Run `DocxWorkbench.App` or `StandardsWorkbench.App` from Visual Studio/VS Code. Microsoft Word is required only for DOCX generation and PDF export. Ollama is configured at `http://192.168.200.112:11434`; the model must be selected from `/api/tags`.
|
||||
@@ -1,7 +0,0 @@
|
||||
# DOCX template contract
|
||||
|
||||
The preferred insertion point is a Content Control with tag or title `DOC_BODY`, then a bookmark named `DOC_BODY`, then the exact marker `{{DOC_BODY}}`. If none is present, generation fails explicitly; the body is never silently appended.
|
||||
|
||||
The template is copied to a temporary working file. Body headers/footers are not imported. Template sections and headers/footers are retained, fields are updated, and Word repaginates before saving.
|
||||
|
||||
Before Word starts, the template is checked for the insertion point. If none is found, the build is rejected without starting Word. The GUI routes `.docx` through Open XML and `.txt` through `TextDocumentInspector`; template and body analysis results remain independent.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Standards rule model
|
||||
|
||||
PDF metadata includes SHA-256, page count, text-layer status and import time. Pages without text are marked `OCR_REQUIRED`; no text is invented. Clause extraction preserves clause number, text, page and confidence.
|
||||
|
||||
Ollama receives only a small clause context and may return a candidate JSON rule. Candidates remain `New` until human approval. Only `Approved`, `Implemented` or `Tested` rules are evaluated. Profiles contain manifest, approved rules, references and a signature hash.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Learned document templates
|
||||
|
||||
DOCX Workbench supports two deterministic workflows:
|
||||
|
||||
1. `TemplateLearningService` normalizes a `.doc` or copies a `.docx` into a temporary working directory, inspects it, adds a safe `{{DOC_BODY}}` insertion marker to the working copy, and writes a portable `.dtemplate` ZIP package.
|
||||
2. `TemplateDocumentGenerator` opens the package, replaces explicitly supplied scalar tokens, imports a `.doc`, `.docx`, or `.txt` body through the existing main-story importer, and creates DOCX, PDF, generation, and validation outputs.
|
||||
|
||||
The package contains `manifest.json`, `template.docx`, source metadata, page archetypes, regions, slots, field bindings, mapping suggestions, and a validation profile. It contains no original source binary unless it is the normalized template copy.
|
||||
|
||||
All Word automation is late-bound and serialized through the existing STA queue. Binary `.doc` sources are opened read-only, macros are disabled through Word automation security, and the source SHA-256 is checked before and after normalization. The source body’s headers, footers, section properties, and page setup are excluded by `DocxBodyImporter`.
|
||||
|
||||
The current UI exposes three modes: template learning, document generation, and the existing quick `DOC_BODY` path. Page archetypes and slot proposals are deterministic suggestions and require later user confirmation; Ollama is not required for the workflow.
|
||||
|
||||
Known limitation: the first slice stores the learned structural model and default slots, but does not yet provide a pixel-level page editor or automatic extraction of every scalar field from an arbitrary engineering drawing. Those operations remain explicit user mapping work.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Word COM lifecycle
|
||||
|
||||
Word is resolved late-bound through `Word.Application`; the Word PIA and `office.dll` are not required at application load time. Each service owns one serial `StaWordQueue`, and every Word job runs on its dedicated STA thread with `Visible=false` and alerts disabled. The service opens a working copy, performs one operation, updates fields, repaginates, saves, closes the Document, quits the Application, and releases owned COM proxies in `finally`.
|
||||
|
||||
The prototype does not run as a Windows Service, does not kill unrelated `WINWORD.EXE` processes, and does not execute macros. Integration tests compare process IDs before and after each job and verify that no newly-created Word process remains.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Samples
|
||||
|
||||
Реальные проектные DOCX/PDF и нормативные документы сюда не добавляются. Тесты создают синтетические документы во временном каталоге. Для локальной проверки укажите входные файлы через GUI.
|
||||
@@ -1,9 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,171 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DocumentAutomation.Shared;
|
||||
|
||||
public sealed record InspectionWarning
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? Part { get; init; }
|
||||
public string? Location { get; init; }
|
||||
public string? MatchedValue { get; init; }
|
||||
public string? DetectionRule { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DocumentFieldInfo
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Part { get; init; }
|
||||
public int? Section { get; init; }
|
||||
public string? Instruction { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DocumentImageSummary
|
||||
{
|
||||
public int RasterImages { get; init; }
|
||||
public int DrawingObjects { get; init; }
|
||||
public int VmlShapes { get; init; }
|
||||
public int ExternalImages { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DocumentStyleInfo
|
||||
{
|
||||
public required string StyleId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Type { get; init; }
|
||||
public bool IsDefault { get; init; }
|
||||
public string? BasedOn { get; init; }
|
||||
public string? LinkedStyle { get; init; }
|
||||
public int UsageCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SectionPartAssignment
|
||||
{
|
||||
public required int Section { get; init; }
|
||||
public IReadOnlyList<string> Headers { get; init; } = [];
|
||||
public IReadOnlyList<string> Footers { get; init; } = [];
|
||||
public bool LinkToPrevious { get; init; }
|
||||
}
|
||||
|
||||
public sealed record InsertionPointInfo
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public string? Location { get; init; }
|
||||
}
|
||||
|
||||
public enum SectionBreakPolicy { Remove, ConvertToParagraph, ConvertToPageBreak }
|
||||
public enum RevisionImportPolicy { PreserveAndWarn, AcceptAll, RejectAll, BlockImport }
|
||||
public enum CommentImportPolicy { PreserveAndWarn, Exclude, BlockImport }
|
||||
public enum StyleConflictPolicy { KeepSourceFormatting, UseDestinationStyles, MapKnownStyles }
|
||||
|
||||
public sealed record WordOperationProgress
|
||||
{
|
||||
public required string Stage { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public int? Percent { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BodyImportPlan
|
||||
{
|
||||
public required int ParagraphCount { get; init; }
|
||||
public required int TableCount { get; init; }
|
||||
public required int InlineShapeCount { get; init; }
|
||||
public required int FloatingShapeCount { get; init; }
|
||||
public required int InternalSectionBreakCount { get; init; }
|
||||
public required int PageBreakCount { get; init; }
|
||||
public required IReadOnlyList<string> IncludedStories { get; init; }
|
||||
public required IReadOnlyList<string> ExcludedStories { get; init; }
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DocxBodyImportRequest
|
||||
{
|
||||
public required string SourcePath { get; init; }
|
||||
public required object DestinationRangeHandle { get; init; }
|
||||
public SectionBreakPolicy SectionBreakPolicy { get; init; } = SectionBreakPolicy.ConvertToPageBreak;
|
||||
public RevisionImportPolicy RevisionPolicy { get; init; } = RevisionImportPolicy.PreserveAndWarn;
|
||||
public CommentImportPolicy CommentPolicy { get; init; } = CommentImportPolicy.Exclude;
|
||||
public StyleConflictPolicy StyleConflictPolicy { get; init; } = StyleConflictPolicy.KeepSourceFormatting;
|
||||
}
|
||||
|
||||
public sealed record BodyImportResult
|
||||
{
|
||||
public required BodyImportPlan Plan { get; init; }
|
||||
public required string InsertionMode { get; init; }
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DocumentInspectionResult
|
||||
{
|
||||
public required string FilePath { get; init; }
|
||||
public long FileSize { get; init; }
|
||||
public DateTime LastWriteTimeUtc { get; init; }
|
||||
public int Sections { get; init; }
|
||||
public int Paragraphs { get; init; }
|
||||
public int Tables { get; init; }
|
||||
public int Images { get; init; }
|
||||
public DocumentImageSummary ImageSummary { get; init; } = new();
|
||||
public int Headers { get; init; }
|
||||
public int Footers { get; init; }
|
||||
public bool HasPageFields { get; init; }
|
||||
public bool HasNumPagesFields { get; init; }
|
||||
public bool HasSectionPagesFields { get; init; }
|
||||
public IReadOnlyList<DocumentFieldInfo> Fields { get; init; } = [];
|
||||
public int Bookmarks { get; init; }
|
||||
public int ContentControls { get; init; }
|
||||
public int Comments { get; init; }
|
||||
public bool HasTrackedChanges { get; init; }
|
||||
public bool HasProtectedParts { get; init; }
|
||||
public IReadOnlyList<DocumentStyleInfo> StyleDetails { get; init; } = [];
|
||||
public IReadOnlyList<SectionPartAssignment> SectionAssignments { get; init; } = [];
|
||||
public InsertionPointInfo InsertionPoint { get; init; } = new() { Type = "None" };
|
||||
public BodyImportPlan? BodyImportPlan { get; init; }
|
||||
public IReadOnlyList<InspectionWarning> Warnings { get; init; } = [];
|
||||
public IReadOnlyList<string> Styles => StyleDetails.Select(x => x.StyleId).ToArray();
|
||||
}
|
||||
|
||||
public sealed record WordDocumentBuildRequest
|
||||
{
|
||||
public required string TemplatePath { get; init; }
|
||||
public required string BodyPath { get; init; }
|
||||
public required string OutputDocxPath { get; init; }
|
||||
public string? RulesProfilePath { get; init; }
|
||||
public bool PreserveBodyFormatting { get; init; } = true;
|
||||
public bool PreservePageBreaks { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record WordDocumentResult
|
||||
{
|
||||
public required string OutputDocxPath { get; init; }
|
||||
public string? OutputPdfPath { get; init; }
|
||||
public required string LogPath { get; init; }
|
||||
public required TimeSpan Duration { get; init; }
|
||||
public required string InsertionPoint { get; init; }
|
||||
public BodyImportResult? BodyImport { get; init; }
|
||||
}
|
||||
|
||||
public sealed record WordPreviewResult
|
||||
{
|
||||
public required string PdfPath { get; init; }
|
||||
public required TimeSpan Duration { get; init; }
|
||||
}
|
||||
|
||||
public enum RuleEvaluationStatus { Pass, Fail, NotApplicable, NeedsReview, Error }
|
||||
|
||||
public sealed record RuleEvaluationResult
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required RuleEvaluationStatus Status { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? ClauseId { get; init; }
|
||||
public int? SourcePage { get; init; }
|
||||
public string? DocumentLocation { get; init; }
|
||||
public bool CanAutoFix { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OllamaSettings
|
||||
{
|
||||
public string BaseUrl { get; init; } = "http://192.168.200.112:11434";
|
||||
public string Model { get; init; } = "";
|
||||
public int TimeoutSeconds { get; init; } = 120;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
namespace DocumentAutomation.Shared;
|
||||
|
||||
public enum TemplateSlotKind { PlainText, RichText, MainBody, Table, RepeatingTableRows, Image, Signature, Date, DocumentField }
|
||||
public enum TemplateRegionKind { Fixed, Variable, Repeating, Ignored }
|
||||
|
||||
public sealed record TemplateSlotOccurrence
|
||||
{
|
||||
public required string Location { get; init; }
|
||||
public int? Page { get; init; }
|
||||
public string? Story { get; init; }
|
||||
public string? XPath { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TemplateSlot
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required TemplateSlotKind Kind { get; init; }
|
||||
public required string ContentControlTag { get; init; }
|
||||
public bool IsRequired { get; init; }
|
||||
public bool IsRepeating { get; init; }
|
||||
public string? PageArchetypeId { get; init; }
|
||||
public string? RepeatGroup { get; init; }
|
||||
public required IReadOnlyList<TemplateSlotOccurrence> Occurrences { get; init; }
|
||||
}
|
||||
|
||||
public sealed record FixedRegion
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required TemplateRegionKind Kind { get; init; }
|
||||
public required IReadOnlyList<int> Pages { get; init; }
|
||||
public required string Fingerprint { get; init; }
|
||||
public string? Story { get; init; }
|
||||
public string? Location { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RepeatingRegion
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required IReadOnlyList<int> Pages { get; init; }
|
||||
public required string Fingerprint { get; init; }
|
||||
public required TemplateRegionKind Kind { get; init; }
|
||||
public string? Location { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PageArchetype
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public bool IsFirstPage { get; init; }
|
||||
public bool IsRepeating { get; init; }
|
||||
public required IReadOnlyList<int> ExamplePages { get; init; }
|
||||
public required IReadOnlyList<string> FixedRegionIds { get; init; }
|
||||
public required IReadOnlyList<string> SlotIds { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TemplateFieldBinding
|
||||
{
|
||||
public required string SourceField { get; init; }
|
||||
public required string TargetSlot { get; init; }
|
||||
public required string Scope { get; init; }
|
||||
public string? SourceStyle { get; init; }
|
||||
public string? TargetStyle { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SourceDocumentStructure
|
||||
{
|
||||
public required string SourcePath { get; init; }
|
||||
public required string Sha256 { get; init; }
|
||||
public required string NormalizedPath { get; init; }
|
||||
public required string NormalizedSha256 { get; init; }
|
||||
public required int PageCount { get; init; }
|
||||
public required int SectionCount { get; init; }
|
||||
public required IReadOnlyList<string> Stories { get; init; }
|
||||
public required IReadOnlyList<string> RepeatedFingerprints { get; init; }
|
||||
public required DocumentInspectionResult Inspection { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TemplateLearningSuggestion
|
||||
{
|
||||
public required string SuggestionType { get; init; }
|
||||
public required string SourceLocation { get; init; }
|
||||
public string? TargetSlot { get; init; }
|
||||
public string? ProposedName { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public bool RequiresConfirmation { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record LearnedTemplate
|
||||
{
|
||||
public required string TemplateId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string SourceFileName { get; init; }
|
||||
public required string SourceSha256 { get; init; }
|
||||
public required string NormalizedSha256 { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public string MinimumEngineVersion { get; init; } = "1.0";
|
||||
public required SourceDocumentStructure Source { get; init; }
|
||||
public required IReadOnlyList<PageArchetype> PageArchetypes { get; init; }
|
||||
public required IReadOnlyList<FixedRegion> FixedRegions { get; init; }
|
||||
public required IReadOnlyList<RepeatingRegion> RepeatingRegions { get; init; }
|
||||
public required IReadOnlyList<TemplateSlot> Slots { get; init; }
|
||||
public required IReadOnlyList<TemplateFieldBinding> FieldBindings { get; init; }
|
||||
public required IReadOnlyList<TemplateLearningSuggestion> Suggestions { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DocumentNormalizationRequest
|
||||
{
|
||||
public required string SourcePath { get; init; }
|
||||
public required string WorkingDirectory { get; init; }
|
||||
public bool OpenReadOnly { get; init; } = true;
|
||||
public bool DisableMacros { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record DocumentNormalizationResult
|
||||
{
|
||||
public required string SourcePath { get; init; }
|
||||
public required string NormalizedPath { get; init; }
|
||||
public required string SourceSha256 { get; init; }
|
||||
public required string NormalizedSha256 { get; init; }
|
||||
public required bool WasConverted { get; init; }
|
||||
public required TimeSpan Duration { get; init; }
|
||||
public int? PageCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TemplateLearningRequest
|
||||
{
|
||||
public required string SourcePath { get; init; }
|
||||
public required string TemplateName { get; init; }
|
||||
public required string OutputPackagePath { get; init; }
|
||||
public bool CreateDefaultSlots { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record TemplateGenerationRequest
|
||||
{
|
||||
public required string TemplatePackagePath { get; init; }
|
||||
public required string ContentSourcePath { get; init; }
|
||||
public required string OutputDocxPath { get; init; }
|
||||
public IReadOnlyDictionary<string, string> ScalarValues { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed record TemplateGenerationResult
|
||||
{
|
||||
public required string OutputDocxPath { get; init; }
|
||||
public required string OutputPdfPath { get; init; }
|
||||
public required string GenerationLogPath { get; init; }
|
||||
public required string ValidationLogPath { get; init; }
|
||||
public required IReadOnlyList<RuleEvaluationResult> Validation { get; init; }
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
|
||||
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
|
||||
<ProjectReference Include="..\DocxWorkbench.OpenXml\DocxWorkbench.OpenXml.csproj" />
|
||||
<ProjectReference Include="..\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,71 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
using DocxWorkbench.OpenXml;
|
||||
using DocxWorkbench.WordInterop;
|
||||
|
||||
namespace DocxWorkbench.App;
|
||||
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
private readonly ComboBox _mode = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 230 };
|
||||
private readonly TextBox _template = new() { Width = 560 };
|
||||
private readonly TextBox _body = new() { Width = 560 };
|
||||
private readonly TextBox _package = new() { Width = 560 };
|
||||
private readonly TextBox _templateName = new() { Width = 560, Text = "Learned document template" };
|
||||
private readonly TextBox _output = new() { Width = 560 };
|
||||
private readonly RichTextBox _structure = new() { Dock = DockStyle.Fill, ReadOnly = true };
|
||||
private readonly RichTextBox _preview = new() { Dock = DockStyle.Fill, ReadOnly = true };
|
||||
private readonly TextBox _log = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, ReadOnly = true };
|
||||
private readonly TextBox _technicalError = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, ReadOnly = true };
|
||||
private readonly Label _status = new() { AutoSize = true, Text = "Ready" };
|
||||
private readonly Button _analyze; private readonly Button _learn; private readonly Button _generate; private readonly Button _build; private readonly Button _validate; private readonly Button _openDocx; private readonly Button _openPdf;
|
||||
private readonly WorkbenchStateController _state = new();
|
||||
private readonly IDocumentInspector _docx = new OpenXmlDocumentInspector(); private readonly IDocumentInspector _txt = new TextDocumentInspector(); private readonly IWordDocumentService _word = new WordDocumentService();
|
||||
private DocumentInspectionResult? _templateResult; private DocumentInspectionResult? _bodyResult; private string? _builtDocx; private string? _builtPdf;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "DOCX Workbench"; Width = 1200; Height = 780; StartPosition = FormStartPosition.CenterScreen;
|
||||
_mode.Items.AddRange(["Template learning", "Document generation", "Quick DOC_BODY"]); _mode.SelectedIndex = 2; _mode.SelectedIndexChanged += (_, _) => RefreshButtons();
|
||||
var grid = new TableLayoutPanel { Dock = DockStyle.Top, Height = 245, ColumnCount = 3, RowCount = 7, Padding = new Padding(8) }; grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 155)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110));
|
||||
AddControlRow(grid, 0, "Mode", _mode, null); AddControlRow(grid, 1, "Template DOC/DOCX", _template, BrowseTemplate); AddControlRow(grid, 2, "Template name", _templateName, null); AddControlRow(grid, 3, "Content DOC/DOCX/TXT", _body, BrowseBody); AddControlRow(grid, 4, "Package .dtemplate", _package, BrowsePackage); AddControlRow(grid, 5, "Output DOCX", _output, BrowseOutput);
|
||||
var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; _analyze = Button("Analyze", async (_, _) => await AnalyzeAsync()); _learn = Button("Learn template", async (_, _) => await LearnAsync()); _generate = Button("Generate", async (_, _) => await GenerateAsync()); _build = Button("Quick build", async (_, _) => await QuickBuildAsync()); _validate = Button("Validate", async (_, _) => await ValidateAsync()); _openDocx = Button("Open DOCX", (_, _) => Open(_builtDocx)); _openPdf = Button("Open PDF", (_, _) => Open(_builtPdf)); buttons.Controls.AddRange([_analyze, _learn, _generate, _build, _validate, _openDocx, _openPdf]); grid.Controls.Add(buttons, 1, 6); grid.SetColumnSpan(buttons, 2);
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill }; AddTab(tabs, "Structure", _structure); AddTab(tabs, "Preview", _preview); AddTab(tabs, "Log", _log); AddTab(tabs, "Technical error", _technicalError); Controls.Add(tabs); Controls.Add(grid); _status.Dock = DockStyle.Bottom; Controls.Add(_status); foreach (var box in new[] { _template, _body, _package, _output }) box.TextChanged += (_, _) => InputsChanged(); FormClosed += (_, _) => (_word as IDisposable)?.Dispose(); RefreshButtons();
|
||||
}
|
||||
|
||||
private static void AddTab(TabControl tabs, string title, Control content) { var tab = new TabPage(title); tab.Controls.Add(content); tabs.TabPages.Add(tab); }
|
||||
private static void AddControlRow(TableLayoutPanel panel, int row, string title, Control control, EventHandler? browse) { panel.Controls.Add(new Label { Text = title, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row); panel.Controls.Add(control, 1, row); if (browse is not null) { var button = new Button { Text = "Browse...", Width = 95 }; button.Click += browse; panel.Controls.Add(button, 2, row); } }
|
||||
private static Button Button(string text, EventHandler handler) { var button = new Button { Text = text, AutoSize = true }; button.Click += handler; return button; }
|
||||
private void BrowseTemplate(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "Word document|*.doc;*.docx" }; if (dialog.ShowDialog() == DialogResult.OK) _template.Text = dialog.FileName; }
|
||||
private void BrowseBody(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "Word or text|*.doc;*.docx;*.txt" }; if (dialog.ShowDialog() == DialogResult.OK) _body.Text = dialog.FileName; }
|
||||
private void BrowsePackage(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "Learned template|*.dtemplate" }; if (dialog.ShowDialog() == DialogResult.OK) _package.Text = dialog.FileName; }
|
||||
private void BrowseOutput(object? sender, EventArgs e) { using var dialog = new SaveFileDialog { Filter = "DOCX|*.docx", DefaultExt = "docx" }; if (dialog.ShowDialog() == DialogResult.OK) _output.Text = dialog.FileName; }
|
||||
|
||||
private async Task LearnAsync()
|
||||
{
|
||||
try { _status.Text = "Learning template..."; using var normalizer = new WordDocumentNormalizer(); var service = new TemplateLearningService(normalizer, new TemplatePackageStore(), _docx); var learned = await service.LearnAsync(new TemplateLearningRequest { SourcePath = _template.Text, TemplateName = _templateName.Text, OutputPackagePath = _package.Text }, null, CancellationToken.None); _structure.Text = JsonSerializer.Serialize(learned, new JsonSerializerOptions { WriteIndented = true }); AppendLog($"Template package created: {_package.Text}"); _status.Text = "Template learned"; } catch (Exception ex) { ShowError(ex, "Template learning failed"); }
|
||||
}
|
||||
|
||||
private async Task GenerateAsync()
|
||||
{
|
||||
try { _status.Text = "Generating..."; using var normalizer = new WordDocumentNormalizer(); using var generator = new TemplateDocumentGenerator(new TemplatePackageStore(), normalizer, _docx); var result = await generator.GenerateAsync(new TemplateGenerationRequest { TemplatePackagePath = _package.Text, ContentSourcePath = _body.Text, OutputDocxPath = _output.Text }, null, CancellationToken.None); _builtDocx = result.OutputDocxPath; _builtPdf = result.OutputPdfPath; _preview.Text = JsonSerializer.Serialize(result.Validation, new JsonSerializerOptions { WriteIndented = true }); AppendLog(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Generation succeeded"; } catch (Exception ex) { ShowError(ex, "Generation failed"); }
|
||||
}
|
||||
|
||||
private async Task AnalyzeAsync()
|
||||
{
|
||||
_status.Text = "Analyzing..."; var template = await InspectPathAsync(_template.Text); var body = await InspectPathAsync(_body.Text); _templateResult = template.Result; _bodyResult = body.Result; if (_templateResult is not null) ShowStructure(_templateResult); if (_bodyResult is not null) _preview.Text = _body.Text.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? File.ReadAllText(_body.Text) : JsonSerializer.Serialize(_bodyResult, new JsonSerializerOptions { WriteIndented = true }); LogInspection("Template", template); LogInspection("Body", body); _state.InputsSelected(template.Result is not null && body.Result is not null); _state.AnalysisCompleted(template.Result is not null, body.Result is not null, template.Result?.InsertionPoint.Type != "None"); _status.Text = _state.State == WorkbenchState.ReadyToBuild ? "Ready" : "Analysis completed with warnings"; RefreshButtons();
|
||||
}
|
||||
|
||||
private Task<(DocumentInspectionResult? Result, Exception? Error, string Inspector)> InspectPathAsync(string path) => Task.Run(() => { try { if (path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) return (_docx.Inspect(path), (Exception?)null, "OpenXmlDocumentInspector"); if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) return (_txt.Inspect(path), (Exception?)null, "TextDocumentInspector"); throw new UnsupportedDocumentFormatException($"Unsupported format {Path.GetExtension(path)}. Use DOC/DOCX/TXT or normalize DOC first."); } catch (Exception ex) { return ((DocumentInspectionResult?)null, ex, "format-router"); } });
|
||||
private async Task QuickBuildAsync() { if (!_state.CanBuild) { await AnalyzeAsync(); if (!_state.CanBuild) return; } _state.BeginBuild(); RefreshButtons(); try { var result = await _word.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = _template.Text, BodyPath = _body.Text, OutputDocxPath = _output.Text }, null, CancellationToken.None); var pdf = await _word.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); _builtDocx = result.OutputDocxPath; _builtPdf = pdf.PdfPath; _state.BuildSucceeded(); AppendLog(JsonSerializer.Serialize(new { result, pdf }, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Quick build succeeded"; } catch (Exception ex) { _state.BuildFailed(); ShowError(ex, "Build failed"); } finally { RefreshButtons(); } }
|
||||
private async Task ValidateAsync() { if (!_state.CanValidate || _builtDocx is null || !File.Exists(_builtDocx)) return; try { var result = await Task.Run(() => _docx.Inspect(_builtDocx)); AppendLog(JsonSerializer.Serialize(new { label = "Output", result }, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Validation succeeded"; } catch (Exception ex) { ShowError(ex, "Validation failed"); } }
|
||||
private void ShowStructure(DocumentInspectionResult result) => _structure.Text = JsonSerializer.Serialize(new { result.FilePath, result.Sections, result.Paragraphs, result.Tables, result.Headers, result.Footers, result.Fields, result.ImageSummary, result.SectionAssignments, result.StyleDetails, result.Warnings }, new JsonSerializerOptions { WriteIndented = true });
|
||||
private void LogInspection(string label, (DocumentInspectionResult? Result, Exception? Error, string Inspector) value) { AppendLog(JsonSerializer.Serialize(new { label, inspector = value.Inspector, result = value.Result, error = value.Error?.Message }, new JsonSerializerOptions { WriteIndented = true })); if (value.Error is not null) _technicalError.Text = value.Error.Message; }
|
||||
private void InputsChanged() { _builtDocx = null; _builtPdf = null; RefreshButtons(); }
|
||||
private void RefreshButtons() { var mode = _mode.SelectedIndex; var templateSupported = _template.Text.EndsWith(".doc", StringComparison.OrdinalIgnoreCase) || _template.Text.EndsWith(".docx", StringComparison.OrdinalIgnoreCase); _analyze.Enabled = mode != 1 && File.Exists(_template.Text) && File.Exists(_body.Text); _learn.Enabled = mode == 0 && templateSupported && File.Exists(_template.Text) && !string.IsNullOrWhiteSpace(_package.Text); _generate.Enabled = mode == 1 && File.Exists(_package.Text) && File.Exists(_body.Text) && !string.IsNullOrWhiteSpace(_output.Text); _build.Enabled = mode == 2 && _state.CanBuild; _validate.Enabled = _state.CanValidate && _builtDocx is not null && File.Exists(_builtDocx); _openDocx.Enabled = _builtDocx is not null && File.Exists(_builtDocx); _openPdf.Enabled = _builtPdf is not null && File.Exists(_builtPdf); }
|
||||
private void ShowError(Exception ex, string title) { _technicalError.Text = ex.ToString(); AppendLog(title + ": " + ex.Message); _status.Text = title; MessageBox.Show(ex.Message, title, MessageBoxButtons.OK, MessageBoxIcon.Error); }
|
||||
private void AppendLog(string text) => _log.AppendText(text + Environment.NewLine);
|
||||
private static void Open(string? path) { if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace DocxWorkbench.App;
|
||||
|
||||
static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The main entry point for the application.
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
// To customize application configuration such as set high DPI settings or default font,
|
||||
// see https://aka.ms/applicationconfiguration.
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using DocumentAutomation.Shared;
|
||||
|
||||
namespace DocxWorkbench.Core;
|
||||
|
||||
public interface IDocumentInspector
|
||||
{
|
||||
DocumentInspectionResult Inspect(string docxPath);
|
||||
}
|
||||
|
||||
public sealed class UnsupportedDocumentFormatException(string message) : InvalidOperationException(message);
|
||||
|
||||
public enum WorkbenchState { Empty, InputsSelected, Analyzed, ReadyToBuild, Building, BuildSucceeded, BuildFailed, Validating, ValidationSucceeded, ValidationFailed }
|
||||
|
||||
public sealed class WorkbenchStateController
|
||||
{
|
||||
public WorkbenchState State { get; private set; } = WorkbenchState.Empty;
|
||||
public bool CanBuild => State == WorkbenchState.ReadyToBuild;
|
||||
public bool CanValidate => State == WorkbenchState.BuildSucceeded;
|
||||
public bool CanOpenDocx { get; private set; }
|
||||
public bool CanOpenPdf { get; private set; }
|
||||
public void InputsSelected(bool selected) { State = selected ? WorkbenchState.InputsSelected : WorkbenchState.Empty; if (!selected) ClearArtifacts(); }
|
||||
public void AnalysisCompleted(bool templateOk, bool bodyOk, bool insertionPointFound) { State = templateOk && bodyOk && insertionPointFound ? WorkbenchState.ReadyToBuild : WorkbenchState.Analyzed; ClearArtifacts(); }
|
||||
public void BeginBuild() { if (!CanBuild) throw new InvalidOperationException("Build is unavailable in the current state."); State = WorkbenchState.Building; ClearArtifacts(); }
|
||||
public void BuildSucceeded() { State = WorkbenchState.BuildSucceeded; CanOpenDocx = true; CanOpenPdf = true; }
|
||||
public void BuildFailed() { State = WorkbenchState.BuildFailed; ClearArtifacts(); }
|
||||
public void BeginValidation() { if (!CanValidate) throw new InvalidOperationException("Validation is available only after a successful build."); State = WorkbenchState.Validating; }
|
||||
public void ValidationCompleted(bool success) => State = success ? WorkbenchState.ValidationSucceeded : WorkbenchState.ValidationFailed;
|
||||
private void ClearArtifacts() { CanOpenDocx = false; CanOpenPdf = false; }
|
||||
}
|
||||
|
||||
public interface IWordDocumentService
|
||||
{
|
||||
Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken);
|
||||
Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IDocxBodyImporter
|
||||
{
|
||||
Task<BodyImportResult> ImportAsync(DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IWordDocumentNormalizer
|
||||
{
|
||||
Task<DocumentNormalizationResult> NormalizeAsync(DocumentNormalizationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ITemplateLearningService
|
||||
{
|
||||
Task<LearnedTemplate> LearnAsync(TemplateLearningRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ITemplatePackageStore
|
||||
{
|
||||
void Save(string packagePath, LearnedTemplate template, string normalizedDocxPath);
|
||||
(LearnedTemplate Template, string TemplateDocxPath, string WorkingDirectory) Open(string packagePath);
|
||||
}
|
||||
|
||||
public interface ITemplateDocumentGenerator
|
||||
{
|
||||
Task<TemplateGenerationResult> GenerateAsync(TemplateGenerationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public static class DocumentInputValidation
|
||||
{
|
||||
public static void Validate(WordDocumentBuildRequest request)
|
||||
{
|
||||
if (!File.Exists(request.TemplatePath)) throw new FileNotFoundException("Шаблон DOCX не найден.", request.TemplatePath);
|
||||
if (!File.Exists(request.BodyPath)) throw new FileNotFoundException("Файл тела документа не найден.", request.BodyPath);
|
||||
if (!request.TemplatePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Шаблон должен быть DOCX.");
|
||||
if (Path.GetFullPath(request.TemplatePath).Equals(Path.GetFullPath(request.OutputDocxPath), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Нельзя перезаписывать исходный шаблон.");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(request.OutputDocxPath))!);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,77 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.OpenXml;
|
||||
|
||||
public sealed class OpenXmlDocumentInspector : IDocumentInspector
|
||||
{
|
||||
private static readonly string[] KnownFields = ["PAGE", "NUMPAGES", "SECTIONPAGES", "FILENAME"];
|
||||
|
||||
public DocumentInspectionResult Inspect(string docxPath)
|
||||
{
|
||||
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath);
|
||||
if (!docxPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Open XML inspector accepts DOCX only, received {Path.GetExtension(docxPath)}.");
|
||||
var info = new FileInfo(docxPath); using var package = WordprocessingDocument.Open(docxPath, false); var main = package.MainDocumentPart ?? throw new InvalidDataException("The DOCX has no main document part."); var roots = GetRoots(main); var fields = roots.SelectMany(x => FindFields(x.Root, x.Part, x.Section)).ToArray(); var images = CountImages(main, roots);
|
||||
return new DocumentInspectionResult
|
||||
{
|
||||
FilePath = docxPath, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, Sections = Math.Max(1, main.Document.Descendants<SectionProperties>().Count()), Paragraphs = main.Document.Descendants<Paragraph>().Count(), Tables = main.Document.Descendants<Table>().Count(), Images = images.RasterImages, ImageSummary = images,
|
||||
Headers = main.HeaderParts.Count(), Footers = main.FooterParts.Count(), HasPageFields = fields.Any(x => x.Type == "PAGE"), HasNumPagesFields = fields.Any(x => x.Type == "NUMPAGES"), HasSectionPagesFields = fields.Any(x => x.Type == "SECTIONPAGES"), Fields = fields,
|
||||
Bookmarks = main.Document.Descendants<BookmarkStart>().Count(), ContentControls = main.Document.Descendants<SdtElement>().Count(), Comments = main.GetPartsOfType<WordprocessingCommentsPart>().Count(), HasTrackedChanges = main.Document.Descendants<Inserted>().Any() || main.Document.Descendants<Deleted>().Any(), HasProtectedParts = main.Document.Descendants<DocumentProtection>().Any(),
|
||||
StyleDetails = GetStyles(main), SectionAssignments = GetSectionAssignments(main), InsertionPoint = FindInsertionPoint(main.Document), BodyImportPlan = CreateBodyImportPlan(main), Warnings = FindWarnings(roots)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> GetRoots(MainDocumentPart main)
|
||||
{
|
||||
var roots = new List<(OpenXmlPartRootElement, string, int?)> { (main.Document, "MainDocumentPart", null) }; roots.AddRange(main.HeaderParts.Select((p, i) => ((OpenXmlPartRootElement)p.Header, $"HeaderPart[{i + 1}]", (int?)null))); roots.AddRange(main.FooterParts.Select((p, i) => ((OpenXmlPartRootElement)p.Footer, $"FooterPart[{i + 1}]", (int?)null))); if (main.DocumentSettingsPart?.Settings is not null) roots.Add((main.DocumentSettingsPart.Settings, "SettingsPart", null)); if (main.FootnotesPart?.Footnotes is not null) roots.Add((main.FootnotesPart.Footnotes, "FootnotesPart", null)); if (main.EndnotesPart?.Endnotes is not null) roots.Add((main.EndnotesPart.Endnotes, "EndnotesPart", null)); foreach (var comments in main.GetPartsOfType<WordprocessingCommentsPart>()) if (comments.Comments is not null) roots.Add((comments.Comments, "CommentsPart", null)); return roots;
|
||||
}
|
||||
|
||||
private static IEnumerable<DocumentFieldInfo> FindFields(OpenXmlElement root, string part, int? section)
|
||||
{
|
||||
foreach (var simple in root.Descendants<SimpleField>()) { var type = NormalizeField(simple.Instruction?.Value); if (type is not null) yield return new DocumentFieldInfo { Type = type, Part = part, Section = section, Instruction = simple.Instruction?.Value }; }
|
||||
var active = new List<string>(); foreach (var element in root.Descendants()) { if (element is FieldChar begin && begin.FieldCharType?.Value == FieldCharValues.Begin) active.Add(""); else if (element is FieldCode code && active.Count > 0) active[^1] += " " + code.Text; else if (element is FieldChar end && end.FieldCharType?.Value == FieldCharValues.End && active.Count > 0) { var raw = active[^1]; active.RemoveAt(active.Count - 1); var type = NormalizeField(raw); if (type is not null) yield return new DocumentFieldInfo { Type = type, Part = part, Section = section, Instruction = raw.Trim() }; } }
|
||||
}
|
||||
|
||||
private static string? NormalizeField(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var normalized = Regex.Replace(value, @"\s+", " ").Trim(); var name = Regex.Match(normalized, @"(?i)^(PAGE|NUMPAGES|SECTIONPAGES|FILENAME)\b").Value.ToUpperInvariant(); return KnownFields.Contains(name) ? name : null; }
|
||||
|
||||
private static DocumentImageSummary CountImages(MainDocumentPart main, IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> roots)
|
||||
{
|
||||
var imageParts = new HashSet<OpenXmlPart>(); var drawings = roots.Sum(x => x.Root.Descendants<Drawing>().Count()); var shapes = roots.Sum(x => x.Root.Descendants<DocumentFormat.OpenXml.Vml.Shape>().Count()); var external = 0;
|
||||
foreach (var image in main.GetPartsOfType<ImagePart>()) imageParts.Add(image); foreach (var header in main.HeaderParts) { foreach (var image in header.GetPartsOfType<ImagePart>()) imageParts.Add(image); external += header.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); } foreach (var footer in main.FooterParts) { foreach (var image in footer.GetPartsOfType<ImagePart>()) imageParts.Add(image); external += footer.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); }
|
||||
external += main.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); return new DocumentImageSummary { RasterImages = imageParts.Count, DrawingObjects = drawings, VmlShapes = shapes, ExternalImages = external };
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DocumentStyleInfo> GetStyles(MainDocumentPart main)
|
||||
{
|
||||
var usage = main.Document.Descendants<ParagraphStyleId>().GroupBy(x => x.Val?.Value ?? "").ToDictionary(x => x.Key, x => x.Count()); return main.StyleDefinitionsPart?.Styles?.Elements<Style>().Select(style => new DocumentStyleInfo { StyleId = style.StyleId?.Value ?? "", Name = style.StyleName?.Val?.Value, Type = style.Type is null ? null : style.Type.Value.ToString(), IsDefault = style.Default?.Value == true, BasedOn = style.BasedOn?.Val?.Value, UsageCount = usage.GetValueOrDefault(style.StyleId?.Value ?? "") }).Where(x => x.StyleId.Length > 0).ToArray() ?? [];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SectionPartAssignment> GetSectionAssignments(MainDocumentPart main)
|
||||
{
|
||||
var result = new List<SectionPartAssignment>(); var sections = main.Document.Body?.Elements<SectionProperties>().ToArray() ?? []; for (var i = 0; i < sections.Length; i++) { var section = sections[i]; var headers = section.Elements<HeaderReference>().Select(x => $"{x.Type?.Value}:{PartName(main, x.Id?.Value)}").ToArray(); var footers = section.Elements<FooterReference>().Select(x => $"{x.Type?.Value}:{PartName(main, x.Id?.Value)}").ToArray(); result.Add(new SectionPartAssignment { Section = i + 1, Headers = headers, Footers = footers, LinkToPrevious = i > 0 && headers.Length == 0 && footers.Length == 0 }); } return result;
|
||||
}
|
||||
|
||||
private static string PartName(MainDocumentPart main, string? id) { if (id is null) return "missing"; try { return main.GetPartById(id).Uri.ToString(); } catch { return "unresolved:" + id; } }
|
||||
private static InsertionPointInfo FindInsertionPoint(Document root) { if (root.Descendants<SdtElement>().Any(x => string.Equals(x.SdtProperties?.GetFirstChild<Tag>()?.Val?.Value, "DOC_BODY", StringComparison.OrdinalIgnoreCase))) return new InsertionPointInfo { Type = "ContentControl", Location = "MainDocumentPart" }; if (root.Descendants<BookmarkStart>().Any(x => string.Equals(x.Name?.Value, "DOC_BODY", StringComparison.OrdinalIgnoreCase))) return new InsertionPointInfo { Type = "Bookmark", Location = "MainDocumentPart" }; if (root.InnerText.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return new InsertionPointInfo { Type = "TextMarker", Location = "MainDocumentPart" }; return new InsertionPointInfo { Type = "None" }; }
|
||||
private static IReadOnlyList<InspectionWarning> FindWarnings(IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> roots) { var result = new List<InspectionWarning>(); foreach (var root in roots) foreach (Match match in Regex.Matches(root.Root.InnerText, @"(?i)\b(2019|legacy|старый)\b")) result.Add(new InspectionWarning { Code = "PossibleLegacyProjectData", Message = "A value matched the legacy-project heuristic.", Part = root.Part, Location = "text", MatchedValue = match.Value, DetectionRule = "word-boundary: 2019|legacy|старый" }); return result; }
|
||||
private static BodyImportPlan CreateBodyImportPlan(MainDocumentPart main)
|
||||
{
|
||||
var body = main.Document.Body; if (body is null) return new BodyImportPlan { ParagraphCount = 0, TableCount = 0, InlineShapeCount = 0, FloatingShapeCount = 0, InternalSectionBreakCount = 0, PageBreakCount = 0, IncludedStories = ["wdMainTextStory"], ExcludedStories = ["wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFirstPageFooterStory", "wdEvenPagesFooterStory"], Warnings = [] };
|
||||
var drawings = body.Descendants<Drawing>(); return new BodyImportPlan { ParagraphCount = body.Descendants<Paragraph>().Count(), TableCount = body.Descendants<Table>().Count(), InlineShapeCount = drawings.Count(x => x.Ancestors<DocumentFormat.OpenXml.Drawing.Wordprocessing.Inline>().Any()), FloatingShapeCount = drawings.Count(x => x.Ancestors<DocumentFormat.OpenXml.Drawing.Wordprocessing.Anchor>().Any()), InternalSectionBreakCount = Math.Max(0, body.Descendants<SectionProperties>().Count() - 1), PageBreakCount = body.Descendants<Break>().Count(x => x.Type?.Value == BreakValues.Page), IncludedStories = ["wdMainTextStory"], ExcludedStories = ["wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFirstPageFooterStory", "wdEvenPagesFooterStory"], Warnings = ["Source headers, footers, page setup and final section properties are excluded."] };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TextDocumentInspector : IDocumentInspector
|
||||
{
|
||||
public DocumentInspectionResult Inspect(string path) { if (!File.Exists(path)) throw new FileNotFoundException("TXT was not found.", path); if (!path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"TXT inspector accepts TXT only, received {Path.GetExtension(path)}."); var info = new FileInfo(path); return new DocumentInspectionResult { FilePath = path, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, Sections = 1, Paragraphs = File.ReadAllLines(path).Length, InsertionPoint = new InsertionPointInfo { Type = "None" } }; }
|
||||
}
|
||||
|
||||
public sealed class DocumentInspector : IDocumentInspector
|
||||
{
|
||||
private readonly OpenXmlDocumentInspector _inner = new();
|
||||
public DocumentInspectionResult Inspect(string docxPath) => _inner.Inspect(docxPath);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
|
||||
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,73 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.OpenXml;
|
||||
|
||||
public sealed class TemplatePackageStore : ITemplatePackageStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
public void Save(string packagePath, LearnedTemplate template, string normalizedDocxPath)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(packagePath))!);
|
||||
if (File.Exists(packagePath)) File.Delete(packagePath);
|
||||
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
|
||||
WriteJson(archive, "manifest.json", new { schemaVersion = "1.0", templateId = template.TemplateId, name = template.Name, sourceFileName = template.SourceFileName, sourceSha256 = template.SourceSha256, normalizedSha256 = template.NormalizedSha256, createdAt = template.CreatedAt, minimumEngineVersion = template.MinimumEngineVersion });
|
||||
WriteJson(archive, "source-metadata.json", template.Source);
|
||||
WriteJson(archive, "page-archetypes.json", template.PageArchetypes);
|
||||
WriteJson(archive, "fixed-regions.json", template.FixedRegions.Concat(template.RepeatingRegions.Select(x => new FixedRegion { Id = x.Id, Name = x.Name, Kind = x.Kind, Pages = x.Pages, Fingerprint = x.Fingerprint, Location = x.Location })).ToArray());
|
||||
WriteJson(archive, "slots.json", template.Slots);
|
||||
WriteJson(archive, "field-bindings.json", template.FieldBindings);
|
||||
WriteJson(archive, "mapping-rules.json", template.Suggestions);
|
||||
WriteJson(archive, "validation-profile.json", new { schemaVersion = "1.0", requiredSlots = template.Slots.Where(x => x.IsRequired).Select(x => x.ContentControlTag).ToArray(), allowTrackedChanges = false, allowExternalLinks = false });
|
||||
var entry = archive.CreateEntry("template.docx", CompressionLevel.Optimal); using var input = File.OpenRead(normalizedDocxPath); using var output = entry.Open(); input.CopyTo(output);
|
||||
}
|
||||
|
||||
public (LearnedTemplate Template, string TemplateDocxPath, string WorkingDirectory) Open(string packagePath)
|
||||
{
|
||||
if (!File.Exists(packagePath)) throw new FileNotFoundException("Template package was not found.", packagePath);
|
||||
var work = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "template-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(work); ZipFile.ExtractToDirectory(packagePath, work);
|
||||
var manifest = ReadJson<Manifest>(Path.Combine(work, "manifest.json")); if (manifest.SchemaVersion != "1.0") throw new InvalidDataException($"Unsupported template schema: {manifest.SchemaVersion}");
|
||||
var template = new LearnedTemplate { TemplateId = manifest.TemplateId, Name = manifest.Name, SourceFileName = manifest.SourceFileName, SourceSha256 = manifest.SourceSha256, NormalizedSha256 = manifest.NormalizedSha256, CreatedAt = manifest.CreatedAt, MinimumEngineVersion = manifest.MinimumEngineVersion, Source = ReadJson<SourceDocumentStructure>(Path.Combine(work, "source-metadata.json")), PageArchetypes = ReadJson<List<PageArchetype>>(Path.Combine(work, "page-archetypes.json")), FixedRegions = ReadJson<List<FixedRegion>>(Path.Combine(work, "fixed-regions.json")).Where(x => x.Kind != TemplateRegionKind.Repeating).ToArray(), RepeatingRegions = ReadJson<List<FixedRegion>>(Path.Combine(work, "fixed-regions.json")).Where(x => x.Kind == TemplateRegionKind.Repeating).Select(x => new RepeatingRegion { Id = x.Id, Name = x.Name, Pages = x.Pages, Fingerprint = x.Fingerprint, Kind = x.Kind, Location = x.Location }).ToArray(), Slots = ReadJson<List<TemplateSlot>>(Path.Combine(work, "slots.json")), FieldBindings = ReadJson<List<TemplateFieldBinding>>(Path.Combine(work, "field-bindings.json")), Suggestions = ReadJson<List<TemplateLearningSuggestion>>(Path.Combine(work, "mapping-rules.json")) };
|
||||
var docx = Path.Combine(work, "template.docx"); if (!File.Exists(docx)) throw new InvalidDataException("Template package does not contain template.docx."); return (template, docx, work);
|
||||
}
|
||||
|
||||
private static void WriteJson<T>(ZipArchive archive, string name, T value) { var entry = archive.CreateEntry(name, CompressionLevel.Optimal); using var writer = new StreamWriter(entry.Open()); writer.Write(JsonSerializer.Serialize(value, JsonOptions)); }
|
||||
private static T ReadJson<T>(string path) => JsonSerializer.Deserialize<T>(File.ReadAllText(path), JsonOptions) ?? throw new InvalidDataException($"Invalid template package entry: {Path.GetFileName(path)}");
|
||||
private sealed record Manifest(string SchemaVersion, string TemplateId, string Name, string SourceFileName, string SourceSha256, string NormalizedSha256, string CreatedAt, string MinimumEngineVersion);
|
||||
}
|
||||
|
||||
public sealed class TemplateLearningService : ITemplateLearningService
|
||||
{
|
||||
private readonly IWordDocumentNormalizer _normalizer;
|
||||
private readonly ITemplatePackageStore _packages;
|
||||
private readonly IDocumentInspector _inspector;
|
||||
public TemplateLearningService(IWordDocumentNormalizer normalizer, ITemplatePackageStore packages, IDocumentInspector? inspector = null) { _normalizer = normalizer; _packages = packages; _inspector = inspector ?? new OpenXmlDocumentInspector(); }
|
||||
|
||||
public async Task<LearnedTemplate> LearnAsync(TemplateLearningRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Template source was not found.", request.SourcePath);
|
||||
var normalized = await _normalizer.NormalizeAsync(new DocumentNormalizationRequest { SourcePath = request.SourcePath, WorkingDirectory = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "learning-" + Guid.NewGuid().ToString("N")) }, progress, cancellationToken);
|
||||
try
|
||||
{
|
||||
progress?.Report(new WordOperationProgress { Stage = "AnalyzeTemplate", Message = "Inspecting normalized template" });
|
||||
EnsureBodyMarker(normalized.NormalizedPath); var normalizedHash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(normalized.NormalizedPath))); var inspection = _inspector.Inspect(normalized.NormalizedPath) with { FilePath = "template.docx" }; var pages = Math.Max(1, normalized.PageCount ?? inspection.Sections); var archetypes = CreateArchetypes(pages, inspection); var repeated = FindRepeatedRegions(inspection); var slots = request.CreateDefaultSlots ? DefaultSlots() : [];
|
||||
var source = new SourceDocumentStructure { SourcePath = Path.GetFileName(request.SourcePath), Sha256 = normalized.SourceSha256, NormalizedPath = "template.docx", NormalizedSha256 = normalizedHash, PageCount = pages, SectionCount = inspection.Sections, Stories = ["wdMainTextStory", "wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFootnotesStory", "wdEndnotesStory", "wdCommentsStory", "wdTextFrameStory"], RepeatedFingerprints = repeated.Select(x => x.Fingerprint).ToArray(), Inspection = inspection };
|
||||
var learned = new LearnedTemplate { TemplateId = Guid.NewGuid().ToString("D"), Name = request.TemplateName, SourceFileName = Path.GetFileName(request.SourcePath), SourceSha256 = normalized.SourceSha256, NormalizedSha256 = normalizedHash, CreatedAt = DateTimeOffset.Now.ToString("O"), Source = source, PageArchetypes = archetypes, FixedRegions = [], RepeatingRegions = repeated, Slots = slots, FieldBindings = [], Suggestions = BuildSuggestions(inspection, slots) };
|
||||
_packages.Save(request.OutputPackagePath, learned, normalized.NormalizedPath); return learned;
|
||||
}
|
||||
finally { TryDeleteDirectory(normalized.NormalizedPath, normalized.SourcePath); }
|
||||
}
|
||||
|
||||
private static void EnsureBodyMarker(string path) { using var doc = WordprocessingDocument.Open(path, true); var main = doc.MainDocumentPart ?? throw new InvalidDataException("Main document part is missing."); var body = main.Document.Body ?? throw new InvalidDataException("Document body is missing."); if (main.Document.InnerText.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return; body.AppendChild(new Paragraph(new Run(new Text("{{DOC_BODY}}")))); main.Document.Save(); }
|
||||
private static IReadOnlyList<PageArchetype> CreateArchetypes(int pages, DocumentInspectionResult inspection) { var result = new List<PageArchetype> { new() { Id = "FirstPage", Name = "FirstPage", IsFirstPage = true, IsRepeating = false, ExamplePages = [1], FixedRegionIds = [], SlotIds = [] } }; if (pages > 1) result.Add(new PageArchetype { Id = "RepeatingPage", Name = "RepeatingPage", IsRepeating = true, ExamplePages = Enumerable.Range(2, pages - 1).ToArray(), FixedRegionIds = [], SlotIds = [] }); return result; }
|
||||
private static IReadOnlyList<RepeatingRegion> FindRepeatedRegions(DocumentInspectionResult inspection) => inspection.SectionAssignments.GroupBy(x => string.Join("|", x.Headers.Concat(x.Footers))).Where(x => x.Count() > 1 && x.Key.Length > 0).Select((x, i) => new RepeatingRegion { Id = "region-" + (i + 1), Name = "Repeated header/footer", Pages = x.Select(y => y.Section).ToArray(), Fingerprint = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(x.Key))), Kind = TemplateRegionKind.Repeating, Location = "section assignments" }).ToArray();
|
||||
private static IReadOnlyList<TemplateSlot> DefaultSlots() => new[] { "PROJECT.CODE", "PROJECT.NAME", "PROJECT.OBJECT", "DOCUMENT.CODE", "DOCUMENT.TITLE", "DOCUMENT.STAGE", "DOCUMENT.SHEET", "DOCUMENT.SHEET_COUNT", "REGISTER.CONTENTS", "REGISTER.MAIN_DRAWING_SETS", "REGISTER.DRAWINGS", "REGISTER.REFERENCE_DOCUMENTS", "DOCUMENT.GENERAL_NOTES", "DOCUMENT.MAIN_BODY" }.Select(x => new TemplateSlot { Id = x, Name = x, Kind = x == "DOCUMENT.MAIN_BODY" ? TemplateSlotKind.MainBody : TemplateSlotKind.PlainText, ContentControlTag = x, IsRequired = x is "DOCUMENT.CODE" or "DOCUMENT.TITLE" or "DOCUMENT.MAIN_BODY", Occurrences = [] }).ToArray();
|
||||
private static IReadOnlyList<TemplateLearningSuggestion> BuildSuggestions(DocumentInspectionResult inspection, IReadOnlyList<TemplateSlot> slots) => [new TemplateLearningSuggestion { SuggestionType = "PageArchetype", SourceLocation = "document", ProposedName = inspection.Sections > 1 ? "FirstPage + RepeatingPage" : "SinglePage", Confidence = 0.5, Reason = "Initial deterministic proposal based on document sections." }];
|
||||
private static void TryDeleteDirectory(string normalized, string source) { var directory = Path.GetDirectoryName(normalized); if (directory is not null && !Path.GetFullPath(source).Equals(Path.GetFullPath(normalized), StringComparison.OrdinalIgnoreCase)) try { if (Directory.Exists(directory)) Directory.Delete(directory, true); } catch { } }
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.WordInterop;
|
||||
|
||||
public sealed class DocxBodyImporter : IDocxBodyImporter, IDisposable
|
||||
{
|
||||
private readonly StaWordQueue _queue;
|
||||
private readonly bool _ownsQueue;
|
||||
public DocxBodyImporter() : this(new StaWordQueue(), true) { }
|
||||
internal DocxBodyImporter(StaWordQueue queue) : this(queue, false) { }
|
||||
private DocxBodyImporter(StaWordQueue queue, bool ownsQueue) { _queue = queue; _ownsQueue = ownsQueue; }
|
||||
|
||||
public Task<BodyImportResult> ImportAsync(DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
|
||||
=> _queue.Enqueue(() => ImportStandalone(request, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private BodyImportResult ImportStandalone(DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken token)
|
||||
{
|
||||
dynamic? application = null; var proxies = new List<object?>(); try { var type = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Activator.CreateInstance(type) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; return ImportOnSta(application, null, request.DestinationRangeHandle, request, progress, token, proxies); } finally { try { if (application is not null) application.Quit(0); } catch { } Release(application); ReleaseAll(proxies); }
|
||||
}
|
||||
|
||||
internal BodyImportResult ImportOnSta(dynamic application, dynamic destinationDocument, dynamic destinationRange, DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken token, List<object?> proxies)
|
||||
{
|
||||
if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Source DOCX was not found.", request.SourcePath); if (!request.SourcePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException("DocxBodyImporter accepts DOCX only.");
|
||||
var plan = BuildPlanFromOpenXml(request.SourcePath);
|
||||
var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "body-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); var sourceCopy = Path.Combine(temp, "source.docx"); File.Copy(request.SourcePath, sourceCopy, true); NormalizeSourceSections(sourceCopy, request.SectionBreakPolicy); dynamic? sourceDocument = null; dynamic? sourceDocuments = null; dynamic? sourceRange = null; dynamic? importRange = null; var localProxies = new List<object?>();
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested(); progress?.Report(new WordOperationProgress { Stage = "OpenSource", Message = "Opening source DOCX read-only" }); sourceDocuments = Own(application.Documents, localProxies); sourceDocument = Own(sourceDocuments.Open(sourceCopy, true, false, false), localProxies); dynamic stories = Own(sourceDocument.StoryRanges, localProxies); sourceRange = Own(stories[1], localProxies); var start = (int)sourceRange.Start; var end = (int)sourceRange.End; if (end > start) end -= 1; importRange = Own(sourceDocument.Range(start, end), localProxies);
|
||||
var warnings = new List<string>(); var sourceRevisions = TryCount(importRange.Revisions); var sourceComments = TryCount(importRange.Comments); if (sourceRevisions > 0) { if (request.RevisionPolicy == RevisionImportPolicy.BlockImport) throw new WordAutomationException("Source DOCX contains tracked changes and RevisionPolicy is BlockImport."); if (request.RevisionPolicy == RevisionImportPolicy.AcceptAll) importRange.Revisions.AcceptAll(); if (request.RevisionPolicy == RevisionImportPolicy.RejectAll) importRange.Revisions.RejectAll(); if (request.RevisionPolicy == RevisionImportPolicy.PreserveAndWarn) warnings.Add($"Tracked changes preserved: {sourceRevisions}."); } if (sourceComments > 0) { if (request.CommentPolicy == CommentImportPolicy.BlockImport) throw new WordAutomationException("Source DOCX contains comments and CommentPolicy is BlockImport."); if (request.CommentPolicy == CommentImportPolicy.PreserveAndWarn) warnings.Add($"Comments preserved: {sourceComments}."); }
|
||||
progress?.Report(new WordOperationProgress { Stage = "FormattedText", Message = "Copying only the main text story" }); destinationRange.FormattedText = importRange.FormattedText; if (request.CommentPolicy == CommentImportPolicy.Exclude) RemoveImportedComments(destinationRange); if (request.RevisionPolicy == RevisionImportPolicy.PreserveAndWarn) warnings.Add("Revision policy: PreserveAndWarn.");
|
||||
plan = plan with { Warnings = warnings }; return new BodyImportResult { Plan = plan, InsertionMode = "FormattedText", Warnings = warnings };
|
||||
}
|
||||
finally { try { if (sourceDocument is not null) sourceDocument.Close(0); } catch { } ReleaseAll(localProxies); TryDelete(temp); }
|
||||
}
|
||||
|
||||
private static BodyImportPlan BuildPlanFromOpenXml(string path)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(path);
|
||||
var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("Source DOCX document.xml is missing.");
|
||||
using var reader = new StreamReader(entry.Open());
|
||||
var document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace);
|
||||
var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
|
||||
var wp = XNamespace.Get("http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing");
|
||||
var body = document.Root?.Element(w + "body") ?? throw new InvalidDataException("Source DOCX body is missing.");
|
||||
var sectionMarks = body.Descendants(w + "sectPr").ToList();
|
||||
var internalSections = Math.Max(0, sectionMarks.Count - 1);
|
||||
var pageBreaks = body.Descendants(w + "br").Count(x => string.Equals((string?)x.Attribute(w + "type"), "page", StringComparison.OrdinalIgnoreCase));
|
||||
return new BodyImportPlan
|
||||
{
|
||||
ParagraphCount = body.Descendants(w + "p").Count(),
|
||||
TableCount = body.Descendants(w + "tbl").Count(),
|
||||
InlineShapeCount = body.Descendants(wp + "inline").Count(),
|
||||
FloatingShapeCount = body.Descendants(wp + "anchor").Count(),
|
||||
InternalSectionBreakCount = internalSections,
|
||||
PageBreakCount = pageBreaks,
|
||||
IncludedStories = ["wdMainTextStory"],
|
||||
ExcludedStories = ["wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFirstPageFooterStory", "wdEvenPagesFooterStory"],
|
||||
Warnings = []
|
||||
};
|
||||
}
|
||||
|
||||
private static void NormalizeSourceSections(string path, SectionBreakPolicy policy)
|
||||
{
|
||||
if (policy == SectionBreakPolicy.ConvertToParagraph) policy = SectionBreakPolicy.Remove;
|
||||
using var archive = ZipFile.Open(path, ZipArchiveMode.Update); var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("Source DOCX document.xml is missing."); string xml; using (var reader = new StreamReader(entry.Open())) xml = reader.ReadToEnd(); var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main"); var document = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); var body = document.Root?.Element(w + "body") ?? throw new InvalidDataException("Source DOCX body is missing."); var sections = body.Descendants(w + "sectPr").ToList(); for (var i = 0; i < sections.Count; i++) { var section = sections[i]; var parent = section.Parent; var paragraph = parent?.Name == w + "pPr" ? parent.Parent : null; section.Remove(); if (i < sections.Count - 1 && policy == SectionBreakPolicy.ConvertToPageBreak && paragraph is not null) paragraph.Add(new XElement(w + "r", new XElement(w + "br", new XAttribute(w + "type", "page")))); } entry.Delete(); var replacement = archive.CreateEntry("word/document.xml", CompressionLevel.Optimal); using var stream = replacement.Open(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); document.Save(writer, SaveOptions.DisableFormatting);
|
||||
}
|
||||
|
||||
private static void RemoveImportedComments(dynamic range) { try { dynamic comments = range.Comments; for (var i = comments.Count; i >= 1; i--) comments[i].Delete(); } catch { } }
|
||||
private static int TryCount(dynamic collection) { try { return (int)collection.Count; } catch { return 0; } }
|
||||
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
|
||||
private static void Release(object? value) { try { if (value is not null && Marshal.IsComObject(value)) Marshal.FinalReleaseComObject(value); } catch { } }
|
||||
private static void ReleaseAll(IEnumerable<object?> values) { foreach (var value in values.Reverse()) Release(value); }
|
||||
private static void TryDelete(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } }
|
||||
public void Dispose() { if (_ownsQueue) _queue.Dispose(); }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
|
||||
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,3 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("DocxWorkbench.Tests")]
|
||||
@@ -1,60 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace DocxWorkbench.WordInterop;
|
||||
|
||||
internal sealed class StaWordQueue : IDisposable
|
||||
{
|
||||
private readonly BlockingCollection<Action> _jobs = new();
|
||||
private readonly Thread _thread;
|
||||
private readonly ManualResetEventSlim _stopped = new(false);
|
||||
private readonly object _lifetimeGate = new();
|
||||
private bool _accepting = true;
|
||||
|
||||
public StaWordQueue()
|
||||
{
|
||||
_thread = new Thread(Run) { IsBackground = true, Name = "DocumentAutomation.Word.STA" };
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public Task<T> Enqueue<T>(Func<T> operation, CancellationToken cancellationToken)
|
||||
{
|
||||
if (operation is null) throw new ArgumentNullException(nameof(operation));
|
||||
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled<T>(cancellationToken);
|
||||
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
lock (_lifetimeGate)
|
||||
{
|
||||
if (!_accepting) throw new ObjectDisposedException(nameof(StaWordQueue));
|
||||
try { _jobs.Add(() => Execute(operation, completion, cancellationToken), cancellationToken); }
|
||||
catch (InvalidOperationException) { throw new ObjectDisposedException(nameof(StaWordQueue)); }
|
||||
catch (OperationCanceledException) { completion.TrySetCanceled(cancellationToken); }
|
||||
}
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
private static void Execute<T>(Func<T> operation, TaskCompletionSource<T> completion, CancellationToken cancellationToken)
|
||||
{
|
||||
try { cancellationToken.ThrowIfCancellationRequested(); completion.TrySetResult(operation()); }
|
||||
catch (OperationCanceledException ex) { completion.TrySetCanceled(ex.CancellationToken); }
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
try { foreach (var job in _jobs.GetConsumingEnumerable()) job(); }
|
||||
finally { _stopped.Set(); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lifetimeGate)
|
||||
{
|
||||
if (!_accepting) return;
|
||||
_accepting = false;
|
||||
_jobs.CompleteAdding();
|
||||
}
|
||||
if (Thread.CurrentThread != _thread) _stopped.Wait(TimeSpan.FromSeconds(30));
|
||||
// Do not dispose the collection or stop event while the STA worker may
|
||||
// still be draining it. The worker owns the final enumeration lifetime.
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.WordInterop;
|
||||
|
||||
public sealed class TemplateDocumentGenerator : ITemplateDocumentGenerator, IDisposable
|
||||
{
|
||||
private readonly ITemplatePackageStore _packages;
|
||||
private readonly IWordDocumentNormalizer _normalizer;
|
||||
private readonly WordDocumentService _word = new();
|
||||
private readonly IDocumentInspector _inspector;
|
||||
public TemplateDocumentGenerator(ITemplatePackageStore packages, IWordDocumentNormalizer normalizer, IDocumentInspector inspector) { _packages = packages; _normalizer = normalizer; _inspector = inspector; }
|
||||
|
||||
public async Task<TemplateGenerationResult> GenerateAsync(TemplateGenerationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var opened = _packages.Open(request.TemplatePackagePath); DocumentNormalizationResult? normalizedBody = null; var outputPdf = Path.ChangeExtension(request.OutputDocxPath, ".pdf");
|
||||
try
|
||||
{
|
||||
var bodyPath = request.ContentSourcePath;
|
||||
if (Path.GetExtension(bodyPath).Equals(".doc", StringComparison.OrdinalIgnoreCase)) { normalizedBody = await _normalizer.NormalizeAsync(new DocumentNormalizationRequest { SourcePath = bodyPath, WorkingDirectory = Path.Combine(opened.WorkingDirectory, "body") }, progress, cancellationToken); bodyPath = normalizedBody.NormalizedPath; }
|
||||
var workingTemplate = Path.Combine(opened.WorkingDirectory, "working-template.docx"); File.Copy(opened.TemplateDocxPath, workingTemplate, true); ReplaceScalarTokens(workingTemplate, request.ScalarValues); progress?.Report(new WordOperationProgress { Stage = "Generate", Message = "Building document from learned template" });
|
||||
var result = await _word.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = workingTemplate, BodyPath = bodyPath, OutputDocxPath = request.OutputDocxPath }, progress is null ? null : new Progress<string>(x => progress.Report(new WordOperationProgress { Stage = "Generate", Message = x })), cancellationToken); await _word.ExportPdfAsync(result.OutputDocxPath, outputPdf, cancellationToken);
|
||||
var validation = Validate(result.OutputDocxPath, opened.Template.TemplateId); var validationPath = Path.ChangeExtension(request.OutputDocxPath, ".validation.json"); File.WriteAllText(validationPath, JsonSerializer.Serialize(validation, new JsonSerializerOptions { WriteIndented = true })); return new TemplateGenerationResult { OutputDocxPath = result.OutputDocxPath, OutputPdfPath = outputPdf, GenerationLogPath = result.LogPath, ValidationLogPath = validationPath, Validation = validation };
|
||||
}
|
||||
finally { TryDeleteDirectory(opened.WorkingDirectory); }
|
||||
}
|
||||
|
||||
private IReadOnlyList<RuleEvaluationResult> Validate(string output, string templateId)
|
||||
{
|
||||
var inspection = _inspector.Inspect(output); var result = new List<RuleEvaluationResult> { new() { RuleId = "DOCX_EXISTS", Status = File.Exists(output) ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, Message = "Output DOCX exists.", DocumentLocation = output }, new() { RuleId = "NO_BODY_MARKER", Status = inspection.InsertionPoint.Type == "None" ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.NeedsReview, Message = inspection.InsertionPoint.Type == "None" ? "No unresolved insertion marker remains." : "An insertion marker remains in the output.", DocumentLocation = "word/document.xml" }, new() { RuleId = "NO_TRACKED_CHANGES", Status = inspection.HasTrackedChanges ? RuleEvaluationStatus.Fail : RuleEvaluationStatus.Pass, Message = inspection.HasTrackedChanges ? "Tracked changes remain." : "No tracked changes detected." }, new() { RuleId = "TEMPLATE_ID", Status = RuleEvaluationStatus.Pass, Message = $"Generated from template {templateId}." } }; return result;
|
||||
}
|
||||
|
||||
private static void ReplaceScalarTokens(string path, IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
if (values.Count == 0) return; using var archive = ZipFile.Open(path, ZipArchiveMode.Update); var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
|
||||
foreach (var entry in archive.Entries.Where(x => x.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase) && x.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)).ToArray())
|
||||
{
|
||||
XDocument document; using (var reader = new StreamReader(entry.Open())) document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace); var changed = false; foreach (var text in document.Descendants(w + "t")) { var value = text.Value; foreach (var pair in values) { var token = "{{" + pair.Key + "}}"; if (!value.Contains(token, StringComparison.OrdinalIgnoreCase)) continue; value = value.Replace(token, pair.Value, StringComparison.OrdinalIgnoreCase); changed = true; } text.Value = value; } if (!changed) continue; entry.Delete(); var replacement = archive.CreateEntry(entry.FullName, CompressionLevel.Optimal); using var stream = replacement.Open(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); document.Save(writer, SaveOptions.DisableFormatting);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } }
|
||||
public void Dispose() { _word.Dispose(); if (_normalizer is IDisposable disposable) disposable.Dispose(); }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.WordInterop;
|
||||
|
||||
public sealed class WordDocumentNormalizer : IWordDocumentNormalizer, IDisposable
|
||||
{
|
||||
private readonly StaWordQueue _queue;
|
||||
public WordDocumentNormalizer() : this(new StaWordQueue(), true) { }
|
||||
internal WordDocumentNormalizer(StaWordQueue queue) : this(queue, false) { }
|
||||
private readonly bool _ownsQueue;
|
||||
private WordDocumentNormalizer(StaWordQueue queue, bool ownsQueue) { _queue = queue; _ownsQueue = ownsQueue; }
|
||||
|
||||
public Task<DocumentNormalizationResult> NormalizeAsync(DocumentNormalizationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
|
||||
=> _queue.Enqueue(() => NormalizeOnSta(request, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private static DocumentNormalizationResult NormalizeOnSta(DocumentNormalizationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken token)
|
||||
{
|
||||
if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Document source was not found.", request.SourcePath);
|
||||
var extension = Path.GetExtension(request.SourcePath);
|
||||
if (!extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException("Template source must be DOC or DOCX.");
|
||||
var started = Stopwatch.StartNew(); var sourceHash = Hash(request.SourcePath); var work = Path.GetFullPath(request.WorkingDirectory); Directory.CreateDirectory(work); var normalized = Path.Combine(work, "normalized.docx"); token.ThrowIfCancellationRequested();
|
||||
if (extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) { File.Copy(request.SourcePath, normalized, true); return new DocumentNormalizationResult { SourcePath = request.SourcePath, NormalizedPath = normalized, SourceSha256 = sourceHash, NormalizedSha256 = Hash(normalized), WasConverted = false, Duration = started.Elapsed }; }
|
||||
dynamic? application = null; dynamic? documents = null; dynamic? document = null; var proxies = new List<object?>();
|
||||
try
|
||||
{
|
||||
progress?.Report(new WordOperationProgress { Stage = "NormalizeDoc", Message = "Opening binary DOC read-only" }); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; if (request.DisableMacros) { try { application.AutomationSecurity = 3; } catch { } } documents = Own(application.Documents, proxies); document = Own(documents.Open(request.SourcePath, false, true, false), proxies) ?? throw new WordAutomationException("Word could not open the DOC source read-only."); token.ThrowIfCancellationRequested(); progress?.Report(new WordOperationProgress { Stage = "NormalizeDoc", Message = "Saving temporary DOCX" }); document.SaveAs2(normalized, 12); int? pageCount = null; try { document.Repaginate(); pageCount = (int)document.ComputeStatistics(2); } catch { } document.Close(0); document = null; return new DocumentNormalizationResult { SourcePath = request.SourcePath, NormalizedPath = normalized, SourceSha256 = sourceHash, NormalizedSha256 = Hash(normalized), WasConverted = true, Duration = started.Elapsed, PageCount = pageCount };
|
||||
}
|
||||
catch (WordNotInstalledException) { throw; }
|
||||
catch (WordAutomationException) { throw; }
|
||||
catch (Exception ex) { throw new WordAutomationException("DOC normalization failed.", ex); }
|
||||
finally { try { if (document is not null) document.Close(0); } catch { } try { if (application is not null) application.Quit(0); } catch { } ReleaseAll(proxies); }
|
||||
}
|
||||
|
||||
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
|
||||
private static void ReleaseAll(IEnumerable<object?> proxies) { foreach (var value in proxies.Reverse()) try { if (value is not null && Marshal.IsComObject(value)) Marshal.FinalReleaseComObject(value); } catch { } }
|
||||
public void Dispose() { if (_ownsQueue) _queue.Dispose(); }
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.WordInterop;
|
||||
|
||||
public sealed class WordNotInstalledException(string message) : InvalidOperationException(message);
|
||||
public sealed class WordAutomationException(string message, Exception? inner = null) : InvalidOperationException(message, inner);
|
||||
|
||||
public sealed class WordDocumentService : IWordDocumentService, IDisposable
|
||||
{
|
||||
private readonly StaWordQueue _queue = new();
|
||||
private readonly DocxBodyImporter _bodyImporter;
|
||||
public WordDocumentService() => _bodyImporter = new DocxBodyImporter(_queue);
|
||||
|
||||
public Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken)
|
||||
=> _queue.Enqueue(() => BuildOnSta(request, progress, cancellationToken), cancellationToken);
|
||||
|
||||
public Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken)
|
||||
=> _queue.Enqueue(() => ExportPdfOnSta(docxPath, outputPdfPath, cancellationToken), cancellationToken);
|
||||
|
||||
private WordDocumentResult BuildOnSta(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken token)
|
||||
{
|
||||
DocumentInputValidation.Validate(request);
|
||||
if (!request.BodyPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) && !request.BodyPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Unsupported body format: {Path.GetExtension(request.BodyPath)}. Use DOCX or TXT.");
|
||||
var insertion = TemplateInsertionPointReader.Find(request.TemplatePath);
|
||||
if (insertion.Type == "None") throw new WordAutomationException("No DOC_BODY insertion point was found. Add a DOC_BODY content control, DOC_BODY bookmark, or {{DOC_BODY}} marker.");
|
||||
var started = Stopwatch.StartNew(); var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); var workingTemplate = Path.Combine(temp, "template.docx"); var stagedOutput = Path.Combine(temp, "result.docx"); File.Copy(request.TemplatePath, workingTemplate, true);
|
||||
dynamic? application = null; dynamic? documents = null; dynamic? document = null; dynamic? range = null; var proxies = new List<object?>(); int? wordPid = null; BodyImportResult? bodyImport = null;
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested(); progress?.Report("Checking Word.Application COM registration"); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; TrySetAutomationSecurity(application); wordPid = TryGetPid(application); progress?.Report($"Word COM started (PID {wordPid?.ToString() ?? "unknown"})");
|
||||
documents = Own(application.Documents, proxies); document = Own(documents.Open(workingTemplate, false, false, false), proxies) ?? throw new WordAutomationException("Word could not open the working template copy.");
|
||||
range = Own(FindInsertionRange(document, insertion.Type, proxies), proxies) ?? throw new WordAutomationException("The insertion point disappeared while opening the template.");
|
||||
token.ThrowIfCancellationRequested(); progress?.Report($"Inserting {Path.GetExtension(request.BodyPath).ToUpperInvariant()} body"); if (request.BodyPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) range.Text = File.ReadAllText(request.BodyPath).Replace("\r\n", "\r").Replace('\n', '\r'); else bodyImport = _bodyImporter.ImportOnSta(application, document, range, new DocxBodyImportRequest { SourcePath = request.BodyPath, DestinationRangeHandle = range }, progress is null ? null : new Progress<WordOperationProgress>(x => progress.Report(x.Message ?? x.Stage)), token, proxies);
|
||||
token.ThrowIfCancellationRequested(); progress?.Report("Updating fields and pagination"); document.Repaginate(); document.Fields.Update(); UpdateHeadersFooters(document, proxies); document.SaveAs2(stagedOutput, 12); document.Close(0); document = null; RemoveUnreferencedHeaderFooterParts(stagedOutput); CommitOutput(stagedOutput, request.OutputDocxPath);
|
||||
var logPath = Path.ChangeExtension(request.OutputDocxPath, ".generation.json"); File.WriteAllText(logPath, JsonSerializer.Serialize(new { operationId = Guid.NewGuid(), operationType = "BuildDocument", startedAtUtc = DateTime.UtcNow.Subtract(started.Elapsed), completedAtUtc = DateTime.UtcNow, template = request.TemplatePath, templateSha256 = Hash(request.TemplatePath), templateVersion = "1.0", body = request.BodyPath, sourceSha256 = Hash(request.BodyPath), output = request.OutputDocxPath, resultSha256 = Hash(request.OutputDocxPath), insertionPoint = insertion.Type, wordPid, staThread = Thread.CurrentThread.ManagedThreadId, staApartment = Thread.CurrentThread.GetApartmentState().ToString(), durationMs = started.ElapsedMilliseconds, status = "Succeeded", errorCode = (string?)null }, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion.Type, BodyImport = bodyImport };
|
||||
}
|
||||
catch (WordNotInstalledException) { throw; }
|
||||
catch (WordAutomationException) { throw; }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("Word automation failed. See technical details in the operation log.", ex); }
|
||||
finally
|
||||
{
|
||||
try { if (document is not null) document.Close(0); } catch { }
|
||||
try { if (application is not null) application.Quit(0); } catch { }
|
||||
ReleaseAll(proxies); TryDeleteDirectory(temp);
|
||||
}
|
||||
}
|
||||
|
||||
private static WordPreviewResult ExportPdfOnSta(string docxPath, string outputPdfPath, CancellationToken token)
|
||||
{
|
||||
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPdfPath))!); var sw = Stopwatch.StartNew(); dynamic? application = null; dynamic? documents = null; dynamic? document = null; var proxies = new List<object?>();
|
||||
try { token.ThrowIfCancellationRequested(); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; TrySetAutomationSecurity(application); documents = Own(application.Documents, proxies); document = Own(documents.Open(docxPath, true, false, false), proxies) ?? throw new WordAutomationException("Word could not open the DOCX."); document.ExportAsFixedFormat(outputPdfPath, 17); return new WordPreviewResult { PdfPath = outputPdfPath, Duration = sw.Elapsed }; }
|
||||
catch (WordNotInstalledException) { throw; }
|
||||
catch (WordAutomationException) { throw; }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("PDF export through Word failed.", ex); }
|
||||
finally { try { if (document is not null) document.Close(0); } catch { } try { if (application is not null) application.Quit(0); } catch { } ReleaseAll(proxies); }
|
||||
}
|
||||
|
||||
private static dynamic? FindInsertionRange(dynamic document, string type, List<object?> proxies)
|
||||
{
|
||||
if (type == "ContentControl") { dynamic controls = Own(document.ContentControls, proxies); for (var i = 1; i <= controls.Count; i++) { dynamic control = Own(controls[i], proxies); if (string.Equals((string?)control.Tag, "DOC_BODY", StringComparison.OrdinalIgnoreCase) || string.Equals((string?)control.Title, "DOC_BODY", StringComparison.OrdinalIgnoreCase)) return control.Range; } }
|
||||
if (type == "Bookmark") return document.Bookmarks["DOC_BODY"].Range;
|
||||
dynamic search = Own(document.Content, proxies); search.Find.ClearFormatting(); search.Find.Text = "{{DOC_BODY}}"; if (search.Find.Execute()) { search.Text = ""; return search; } return null;
|
||||
}
|
||||
|
||||
private static void UpdateHeadersFooters(dynamic document, List<object?> proxies)
|
||||
{
|
||||
dynamic sections = Own(document.Sections, proxies); for (var i = 1; i <= sections.Count; i++) { dynamic section = Own(sections[i], proxies); UpdateCollection(section.Headers, proxies); UpdateCollection(section.Footers, proxies); }
|
||||
void UpdateCollection(dynamic collection, List<object?> proxies) { dynamic owned = WordDocumentService.Own(collection, proxies); for (var i = 1; i <= owned.Count; i++) { dynamic item = WordDocumentService.Own(owned[i], proxies); dynamic range = WordDocumentService.Own(item.Range, proxies); range.Fields.Update(); } }
|
||||
}
|
||||
|
||||
private static void TrySetAutomationSecurity(dynamic application) { try { application.AutomationSecurity = 3; application.ScreenUpdating = false; } catch { } }
|
||||
private static int? TryGetPid(dynamic application) { try { return Process.GetProcessById((int)application.Hwnd).Id; } catch { return null; } }
|
||||
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
|
||||
private static void ReleaseAll(IEnumerable<object?> proxies) { foreach (var proxy in proxies.Reverse()) try { if (proxy is not null && Marshal.IsComObject(proxy)) Marshal.FinalReleaseComObject(proxy); } catch { } }
|
||||
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
private static void CommitOutput(string staged, string output) { var full = Path.GetFullPath(output); if (File.Exists(full)) File.Copy(full, full + ".bak-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"), true); File.Copy(staged, full, true); }
|
||||
private static void RemoveUnreferencedHeaderFooterParts(string path)
|
||||
{
|
||||
using var archive = ZipFile.Open(path, ZipArchiveMode.Update);
|
||||
var documentEntry = archive.GetEntry("word/document.xml");
|
||||
var relsEntry = archive.GetEntry("word/_rels/document.xml.rels");
|
||||
if (documentEntry is null || relsEntry is null) return;
|
||||
XDocument document;
|
||||
XDocument relationships;
|
||||
using (var reader = new StreamReader(documentEntry.Open())) document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace);
|
||||
using (var reader = new StreamReader(relsEntry.Open())) relationships = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace);
|
||||
var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
|
||||
var rel = XNamespace.Get("http://schemas.openxmlformats.org/package/2006/relationships");
|
||||
var used = document.Descendants(w + "headerReference").Concat(document.Descendants(w + "footerReference")).Select(x => (string?)x.Attribute(XNamespace.Get("http://schemas.openxmlformats.org/officeDocument/2006/relationships") + "id")).Where(x => x is not null).ToHashSet(StringComparer.Ordinal);
|
||||
var removedTargets = new List<string>();
|
||||
foreach (var relationship in relationships.Root?.Elements(rel + "Relationship").ToList() ?? [])
|
||||
{
|
||||
var type = (string?)relationship.Attribute("Type") ?? string.Empty;
|
||||
var id = (string?)relationship.Attribute("Id");
|
||||
if (id is null || (!type.EndsWith("/header", StringComparison.OrdinalIgnoreCase) && !type.EndsWith("/footer", StringComparison.OrdinalIgnoreCase)) || used.Contains(id)) continue;
|
||||
var target = (string?)relationship.Attribute("Target");
|
||||
if (!string.IsNullOrWhiteSpace(target)) removedTargets.Add(ResolveWordPart(target));
|
||||
relationship.Remove();
|
||||
}
|
||||
foreach (var target in removedTargets.Distinct(StringComparer.OrdinalIgnoreCase)) archive.GetEntry(target)?.Delete();
|
||||
relsEntry.Delete();
|
||||
var replacement = archive.CreateEntry("word/_rels/document.xml.rels", CompressionLevel.Optimal);
|
||||
using var stream = replacement.Open();
|
||||
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
|
||||
relationships.Save(writer, SaveOptions.DisableFormatting);
|
||||
}
|
||||
private static string ResolveWordPart(string target) => target.StartsWith("/", StringComparison.Ordinal) ? target.TrimStart('/') : "word/" + target.Replace("\\", "/").TrimStart("./".ToCharArray());
|
||||
private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } }
|
||||
public void Dispose() => _queue.Dispose();
|
||||
}
|
||||
|
||||
internal static class TemplateInsertionPointReader
|
||||
{
|
||||
public static InsertionPointInfo Find(string docxPath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(docxPath); var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("DOCX document.xml is missing."); using var reader = new StreamReader(entry.Open()); var xml = reader.ReadToEnd();
|
||||
if (Regex.IsMatch(xml, @"w:tag[^>]+w:val=""DOC_BODY""", RegexOptions.IgnoreCase)) return new InsertionPointInfo { Type = "ContentControl", Location = "word/document.xml" };
|
||||
if (Regex.IsMatch(xml, @"w:bookmarkStart[^>]+w:name=""DOC_BODY""", RegexOptions.IgnoreCase)) return new InsertionPointInfo { Type = "Bookmark", Location = "word/document.xml" };
|
||||
if (xml.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return new InsertionPointInfo { Type = "TextMarker", Location = "word/document.xml" };
|
||||
return new InsertionPointInfo { Type = "None" };
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using StandardsWorkbench.Core;
|
||||
using StandardsWorkbench.Ollama;
|
||||
using StandardsWorkbench.Pdf;
|
||||
|
||||
namespace StandardsWorkbench.App;
|
||||
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
private readonly TextBox _pdf = new() { Width = 560 }; private readonly TextBox _code = new() { Width = 240, Text = "ГОСТ Р 21.101-2026" }; private readonly TextBox _title = new() { Width = 560, Text = "Нормативный документ" }; private readonly TextBox _log = new() { Multiline = true, Dock = DockStyle.Fill, ScrollBars = ScrollBars.Vertical }; private readonly ListBox _candidates = new() { Dock = DockStyle.Fill }; private readonly Label _status = new() { AutoSize = true, Text = "Готово" }; private readonly IStandardPdfService _service = new PdfStandardService(); private StandardMetadata? _metadata; private List<CandidateRule> _rules = [];
|
||||
public MainForm()
|
||||
{
|
||||
Text = "Standards Workbench"; Width = 900; Height = 620; var top = new TableLayoutPanel { Dock = DockStyle.Top, Height = 150, ColumnCount = 3, RowCount = 4, Padding = new Padding(8) }; top.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 120)); top.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); top.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110)); Add(top, 0, "Стандарт PDF", _pdf, Browse); Add(top, 1, "Код", _code, null); Add(top, 2, "Название", _title, null); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; buttons.Controls.Add(Button("Анализировать PDF", async (_, _) => await ImportAsync())); buttons.Controls.Add(Button("Утвердить выбранное", (_, _) => SetSelectedStatus("Approved"))); buttons.Controls.Add(Button("Отклонить выбранное", (_, _) => SetSelectedStatus("Rejected"))); buttons.Controls.Add(Button("Экспортировать профиль", (_, _) => ExportProfile())); top.Controls.Add(buttons, 1, 3); top.SetColumnSpan(buttons, 2); var tabs = new TabControl { Dock = DockStyle.Fill }; foreach (var t in new[] { "Документ", "Страницы", "Пункты", "Кандидаты правил", "Профили", "Сравнение", "Журнал" }) { var p = new TabPage(t); if (t == "Журнал") p.Controls.Add(_log); if (t == "Кандидаты правил") p.Controls.Add(_candidates); tabs.TabPages.Add(p); } Controls.Add(tabs); Controls.Add(top); Controls.Add(_status); _status.Dock = DockStyle.Bottom;
|
||||
}
|
||||
private static void Add(TableLayoutPanel p, int row, string label, TextBox box, EventHandler? browse) { p.Controls.Add(new Label { Text = label, AutoSize = true }, 0, row); p.Controls.Add(box, 1, row); if (browse is not null) { var b = new Button { Text = "Обзор..." }; b.Click += browse; p.Controls.Add(b, 2, row); } }
|
||||
private static Button Button(string text, EventHandler e) { var b = new Button { Text = text, AutoSize = true }; b.Click += e; return b; }
|
||||
private void Browse(object? s, EventArgs e) { using var d = new OpenFileDialog { Filter = "PDF|*.pdf" }; if (d.ShowDialog() == DialogResult.OK) _pdf.Text = d.FileName; }
|
||||
private async Task ImportAsync() { try { _status.Text = "Анализ PDF..."; var result = await _service.ImportAsync(_pdf.Text, _code.Text, _title.Text, CancellationToken.None); _metadata = result.Metadata; _rules = result.Clauses.Select(c => new CandidateRule { CandidateId = Guid.NewGuid().ToString("N"), StandardId = c.StandardId, ClauseId = c.ClauseId, SourcePage = c.StartPage, SourceText = c.Text, Title = c.Heading.Length > 0 ? c.Heading : $"Кандидат по пункту {c.ClauseId}", RequiresHumanApproval = true }).ToList(); RefreshCandidates(); _log.AppendText(JsonSerializer.Serialize(new { result.Metadata, result.Pages, result.Clauses }, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine); _status.Text = $"Готово: {result.Pages.Count} страниц, {result.Clauses.Count} пунктов"; } catch (Exception ex) { _log.AppendText(ex + Environment.NewLine); _status.Text = "Ошибка"; MessageBox.Show(ex.Message); } }
|
||||
private void RefreshCandidates() { _candidates.Items.Clear(); foreach (var rule in _rules) _candidates.Items.Add($"[{rule.Status}] {rule.ClauseId} стр.{rule.SourcePage}: {rule.Title}"); }
|
||||
private void SetSelectedStatus(string status) { if (_candidates.SelectedIndex < 0 || _candidates.SelectedIndex >= _rules.Count) return; _rules[_candidates.SelectedIndex] = _rules[_candidates.SelectedIndex] with { Status = status }; RefreshCandidates(); }
|
||||
private void ExportProfile() { if (_metadata is null) { MessageBox.Show("Сначала импортируйте PDF."); return; } using var dialog = new FolderBrowserDialog(); if (dialog.ShowDialog() != DialogResult.OK) return; var profile = new ApprovedRulesProfile { StandardId = _metadata.StandardId, Code = _metadata.Code, Revision = _metadata.Revision, SourceFileSha256 = _metadata.SourceFileSha256, Rules = _rules.Where(r => r.Status == "Approved").ToArray() }; var path = Path.Combine(dialog.SelectedPath, $"{_metadata.StandardId}-docx-profile"); ProfileStore.Export(path, profile); _status.Text = $"Профиль экспортирован: {path}"; }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace StandardsWorkbench.App;
|
||||
|
||||
static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The main entry point for the application.
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
// To customize application configuration such as set high DPI settings or default font,
|
||||
// see https://aka.ms/applicationconfiguration.
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
|
||||
<ProjectReference Include="..\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj" />
|
||||
<ProjectReference Include="..\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj" />
|
||||
<ProjectReference Include="..\StandardsWorkbench.Ollama\StandardsWorkbench.Ollama.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,77 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DocumentAutomation.Shared;
|
||||
|
||||
namespace StandardsWorkbench.Core;
|
||||
|
||||
public sealed record StandardMetadata
|
||||
{
|
||||
public required string StandardId { get; init; }
|
||||
public required string Code { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string Revision { get; init; } = "";
|
||||
public DateOnly? EffectiveFrom { get; init; }
|
||||
public required string SourceFileSha256 { get; init; }
|
||||
public int PageCount { get; init; }
|
||||
public bool TextLayerDetected { get; init; }
|
||||
public DateTime ImportedAt { get; init; } = DateTime.UtcNow;
|
||||
public string Status { get; init; } = "Imported";
|
||||
}
|
||||
|
||||
public sealed record PdfPageText(int PageNumber, string Text, bool TextLayerDetected, string Status, IReadOnlyList<string> Blocks);
|
||||
public sealed record StandardClause(string StandardId, string ClauseId, string Heading, string Text, int StartPage, int EndPage, double ExtractionConfidence);
|
||||
public sealed record CandidateRule
|
||||
{
|
||||
public required string CandidateId { get; init; }
|
||||
public required string StandardId { get; init; }
|
||||
public required string ClauseId { get; init; }
|
||||
public int SourcePage { get; init; }
|
||||
public required string SourceText { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string[] Scope { get; init; } = ["DOCX"];
|
||||
public string RuleKind { get; init; } = "ExpertReview";
|
||||
public string Target { get; init; } = "Document";
|
||||
public Dictionary<string, string> Parameters { get; init; } = [];
|
||||
public string Severity { get; init; } = "Warning";
|
||||
public string AutomationLevel { get; init; } = "Manual";
|
||||
public double Confidence { get; init; }
|
||||
public bool RequiresHumanApproval { get; init; } = true;
|
||||
public string Status { get; init; } = "New";
|
||||
}
|
||||
|
||||
public sealed record ApprovedRulesProfile
|
||||
{
|
||||
public required string StandardId { get; init; }
|
||||
public required string Code { get; init; }
|
||||
public required string Revision { get; init; }
|
||||
public required string SourceFileSha256 { get; init; }
|
||||
public string ProfileVersion { get; init; } = "1.0";
|
||||
public string MinimumRulesEngineVersion { get; init; } = "1.0";
|
||||
public DateTime ExportedAt { get; init; } = DateTime.UtcNow;
|
||||
public IReadOnlyList<CandidateRule> Rules { get; init; } = [];
|
||||
}
|
||||
|
||||
public interface IPdfOcrService { Task<PdfPageText> RecognizePageAsync(string pdfPath, int pageNumber, CancellationToken cancellationToken); }
|
||||
public interface IStandardPdfService { Task<(StandardMetadata Metadata, IReadOnlyList<PdfPageText> Pages, IReadOnlyList<StandardClause> Clauses)> ImportAsync(string pdfPath, string code, string title, CancellationToken cancellationToken); }
|
||||
public interface IOllamaService { Task<IReadOnlyList<string>> GetModelsAsync(CancellationToken cancellationToken); Task<CandidateRule?> SuggestCandidateAsync(StandardClause clause, string model, CancellationToken cancellationToken); }
|
||||
|
||||
public static class ProfileStore
|
||||
{
|
||||
public static string Sha256(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
|
||||
public static void Export(string directory, ApprovedRulesProfile profile)
|
||||
{
|
||||
Directory.CreateDirectory(directory); var options = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(Path.Combine(directory, "manifest.json"), JsonSerializer.Serialize(profile, options)); File.WriteAllText(Path.Combine(directory, "approved-rules.json"), JsonSerializer.Serialize(profile.Rules, options)); File.WriteAllText(Path.Combine(directory, "references.json"), JsonSerializer.Serialize(profile.Rules.Select(r => new { r.ClauseId, r.SourcePage, r.StandardId }), options)); File.WriteAllText(Path.Combine(directory, "signature.sha256"), Sha256(Path.Combine(directory, "approved-rules.json")));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RulesEngine
|
||||
{
|
||||
public IReadOnlyList<RuleEvaluationResult> Evaluate(DocumentInspectionResult document, ApprovedRulesProfile profile)
|
||||
=> profile.Rules.Where(r => r.Status is "Approved" or "Implemented" or "Tested").Select(r => EvaluateOne(document, r)).ToArray();
|
||||
|
||||
private static RuleEvaluationResult EvaluateOne(DocumentInspectionResult d, CandidateRule rule)
|
||||
{
|
||||
var status = rule.RuleKind switch { "SectionCount" => d.Sections > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "ContentControlPresence" => d.ContentControls > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "BookmarkPresence" => d.Bookmarks > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "CommentsAbsent" => d.Comments == 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "TrackedChangesAbsent" => !d.HasTrackedChanges ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, _ => RuleEvaluationStatus.NeedsReview };
|
||||
return new RuleEvaluationResult { RuleId = rule.CandidateId, Status = status, Message = status == RuleEvaluationStatus.Pass ? "Проверка пройдена." : status == RuleEvaluationStatus.Fail ? "Проверка не пройдена." : "Требуется экспертная проверка.", ClauseId = rule.ClauseId, SourcePage = rule.SourcePage, DocumentLocation = rule.Target, CanAutoFix = false };
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup><ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" /></ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,17 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StandardsWorkbench.Core;
|
||||
|
||||
namespace StandardsWorkbench.Ollama;
|
||||
|
||||
public sealed class OllamaClient : IOllamaService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
public OllamaClient(HttpClient? httpClient = null) { _http = httpClient ?? new HttpClient(); _http.BaseAddress = new Uri("http://192.168.200.112:11434"); _http.Timeout = TimeSpan.FromSeconds(120); }
|
||||
public async Task<IReadOnlyList<string>> GetModelsAsync(CancellationToken cancellationToken)
|
||||
{ using var response = await _http.GetAsync("/api/tags", cancellationToken); response.EnsureSuccessStatusCode(); using var json = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken: cancellationToken); return json?.RootElement.GetProperty("models").EnumerateArray().Select(x => x.GetProperty("name").GetString() ?? "").Where(x => x.Length > 0).ToArray() ?? []; }
|
||||
public async Task<CandidateRule?> SuggestCandidateAsync(StandardClause clause, string model, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model)) throw new ArgumentException("Модель Ollama не выбрана.", nameof(model)); var prompt = $"Return JSON only. Extract one candidate DOCX rule from clause {clause.ClauseId}. Never invent source references. Text: {clause.Text}"; using var response = await _http.PostAsJsonAsync("/api/generate", new { model, prompt, stream = false, format = "json" }, cancellationToken); response.EnsureSuccessStatusCode(); using var json = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken: cancellationToken); var raw = json?.RootElement.GetProperty("response").GetString(); if (string.IsNullOrWhiteSpace(raw)) return null; try { return JsonSerializer.Deserialize<CandidateRule>(raw); } catch (JsonException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup><ProjectReference Include="..\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj" /></ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StandardsWorkbench.Core;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace StandardsWorkbench.Pdf;
|
||||
|
||||
public sealed class PdfStandardService : IStandardPdfService
|
||||
{
|
||||
public Task<(StandardMetadata Metadata, IReadOnlyList<PdfPageText> Pages, IReadOnlyList<StandardClause> Clauses)> ImportAsync(string pdfPath, string code, string title, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(pdfPath)) throw new FileNotFoundException("PDF ГОСТа не найден.", pdfPath);
|
||||
return Task.Run(() =>
|
||||
{
|
||||
using var pdf = PdfDocument.Open(pdfPath); var pages = new List<PdfPageText>();
|
||||
foreach (var page in pdf.GetPages()) { cancellationToken.ThrowIfCancellationRequested(); var text = page.Text ?? ""; pages.Add(new PdfPageText(page.Number, text, text.Trim().Length > 0, text.Trim().Length > 0 ? "Extracted" : "OCR_REQUIRED", text.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))); }
|
||||
var standardId = Regex.Replace(code.ToUpperInvariant(), "[^A-ZА-Я0-9]+", "-").Trim('-'); var clauses = ExtractClauses(standardId, pages); var meta = new StandardMetadata { StandardId = standardId, Code = code, Title = title, SourceFileSha256 = ProfileStore.Sha256(pdfPath), PageCount = pages.Count, TextLayerDetected = pages.Any(p => p.TextLayerDetected), ImportedAt = DateTime.UtcNow };
|
||||
return (meta, (IReadOnlyList<PdfPageText>)pages, (IReadOnlyList<StandardClause>)clauses);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private static List<StandardClause> ExtractClauses(string id, IReadOnlyList<PdfPageText> pages)
|
||||
{
|
||||
var result = new List<StandardClause>(); string? currentId = null; var currentText = new List<string>(); var start = 1;
|
||||
void Flush(int end) { if (currentId is not null) result.Add(new StandardClause(id, currentId, "", string.Join(Environment.NewLine, currentText), start, end, 0.85)); }
|
||||
foreach (var page in pages) foreach (var line in page.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var match = Regex.Match(line, "^(\\d+(?:\\.\\d+){0,5})[.)]?\\s+(.+)$"); if (match.Success) { Flush(page.PageNumber - 1); currentId = match.Groups[1].Value; currentText = [line]; start = page.PageNumber; } else if (currentId is not null) currentText.Add(line); }
|
||||
Flush(pages.Count); return result;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoOpOcrService : IPdfOcrService
|
||||
{
|
||||
public Task<PdfPageText> RecognizePageAsync(string pdfPath, int pageNumber, CancellationToken cancellationToken) => Task.FromResult(new PdfPageText(pageNumber, "", false, "OCR_REQUIRED", []));
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
|
||||
<ProjectReference Include="..\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.9" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup><ProjectReference Include="..\..\src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" /><ProjectReference Include="..\..\src\DocxWorkbench.OpenXml\DocxWorkbench.OpenXml.csproj" /><ProjectReference Include="..\..\src\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj" /><PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" /></ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.WordInterop;
|
||||
using DocxWorkbench.OpenXml;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace DocumentAutomation.IntegrationTests;
|
||||
|
||||
public sealed class WordComIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void IntegrationAssemblyIsAvailable() => Assert.True(typeof(WordComIntegrationTests).Assembly.GetName().Name is not null);
|
||||
|
||||
[Fact]
|
||||
public void WordAvailability_DoesNotRequireOfficePia()
|
||||
{
|
||||
var names = typeof(WordDocumentService).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray(); Assert.DoesNotContain(names, name => string.Equals(name, "office", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "Microsoft.Office.Interop.Word", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_WithoutInsertionPoint_DoesNotStartWord()
|
||||
{
|
||||
var template = CreateDocx(false); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); File.WriteAllText(body, "text"); var before = ProcessIds(); try { using var service = new WordDocumentService(); await Assert.ThrowsAsync<WordAutomationException>(() => service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx") }, null, CancellationToken.None)); Assert.Empty(ProcessIds().Except(before)); } finally { File.Delete(template); File.Delete(body); }
|
||||
}
|
||||
|
||||
[Fact] public async Task Build_RunsOnStaThread() { await RequireWord(); var result = await BuildTxt(); var log = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(result.LogPath)); Assert.Equal("STA", log.GetProperty("staApartment").GetString()); }
|
||||
[Fact] public async Task Build_WithTxtBody_InsertsText() { await RequireWord(); var result = await BuildTxt(); Assert.Contains("TXT integration body", ReadMainText(result.OutputDocxPath)); }
|
||||
[Fact] public async Task Build_WithDocxBody_InsertsDocument() { await RequireWord(); var template = CreateDocx(true); var body = CreateDocx(false, "DOCX integration body"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); try { using var service = new WordDocumentService(); await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); Assert.Contains("DOCX integration body", ReadMainText(output)); } finally { File.Delete(template); File.Delete(body); File.Delete(output); } }
|
||||
[Fact] public async Task Build_ExportsPdf() { await RequireWord(); var result = await BuildTxt(); using var service = new WordDocumentService(); var pdf = await service.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); Assert.True(File.Exists(pdf.PdfPath)); File.Delete(result.OutputDocxPath); File.Delete(pdf.PdfPath); File.Delete(result.LogPath); }
|
||||
[Fact] public async Task Build_DoesNotLeaveCreatedWinwordProcess() { await RequireWord(); var before = ProcessIds(); var result = await BuildTxt(); await Task.Delay(500); Assert.Empty(ProcessIds().Except(before)); File.Delete(result.OutputDocxPath); File.Delete(result.LogPath); }
|
||||
[Fact] public async Task Build_Failure_ReleasesWord() { await RequireWord(); var before = ProcessIds(); var template = CreateDocx(true); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var outputDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); Directory.CreateDirectory(outputDirectory); File.WriteAllText(body, "failure body"); try { using var service = new WordDocumentService(); await Assert.ThrowsAnyAsync<Exception>(() => service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = outputDirectory }, null, CancellationToken.None)); await Task.Delay(500); Assert.Empty(ProcessIds().Except(before)); } finally { File.Delete(template); File.Delete(body); Directory.Delete(outputDirectory, true); } }
|
||||
[Fact]
|
||||
public async Task RuntimeGate_DesktopDocxBodyUsesTemplateCopy()
|
||||
{
|
||||
await RequireWord(); var template = @"C:\Users\codex\Desktop\156-титул-ПД.docx"; var body = @"C:\Users\codex\Desktop\НХС-33-УК3-ОЗХ-13011-000-НК-ОД.docx"; if (!File.Exists(template) || !File.Exists(body)) throw SkipException.ForSkip("The supplied Desktop DOCX fixtures are not available."); var copy = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); File.Copy(template, copy); try { using (var document = WordprocessingDocument.Open(copy, true)) { var main = document.MainDocumentPart ?? throw new InvalidDataException("Main part missing."); main.Document.Body!.Append(new Paragraph(new Run(new Text("{{DOC_BODY}}")))); main.Document.Save(); } using var service = new WordDocumentService(); var result = await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = copy, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); Assert.True(File.Exists(result.OutputDocxPath)); } finally { File.Delete(copy); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".generation.json")); }
|
||||
}
|
||||
[Fact]
|
||||
public async Task ImportDocxBody_ExcludesSourceHeaders()
|
||||
{
|
||||
await RequireWord(); var template = CreateDocx(true); var source = CreateComplexSource(); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var sourceHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(source))); try { using var service = new WordDocumentService(); var result = await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = source, OutputDocxPath = output }, null, CancellationToken.None); using var document = WordprocessingDocument.Open(result.OutputDocxPath, false); var main = document.MainDocumentPart!; Assert.DoesNotContain(main.HeaderParts.SelectMany(x => x.Header?.Descendants<Text>() ?? []), x => x.Text == "SOURCE HEADER"); Assert.DoesNotContain(main.FooterParts.SelectMany(x => x.Footer?.Descendants<Text>() ?? []), x => x.Text == "SOURCE FOOTER"); Assert.Single(main.Document.Descendants<SectionProperties>()); Assert.NotNull(result.BodyImport); Assert.Contains("wdMainTextStory", result.BodyImport!.Plan.IncludedStories); Assert.Equal(sourceHash, Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(source)))); } finally { File.Delete(template); File.Delete(source); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".generation.json")); }
|
||||
}
|
||||
[Fact]
|
||||
public async Task ImportDocxBody_DoesNotChangeDestinationPageSetup()
|
||||
{
|
||||
await RequireWord(); var template = CreateDocx(true); var source = CreateComplexSource(); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); try { using var service = new WordDocumentService(); var result = await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = source, OutputDocxPath = output }, null, CancellationToken.None); using var document = WordprocessingDocument.Open(result.OutputDocxPath, false); Assert.Single(document.MainDocumentPart!.Document.Descendants<SectionProperties>()); Assert.Contains("DOCX complex body", document.MainDocumentPart.Document.InnerText); } finally { File.Delete(template); File.Delete(source); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".generation.json")); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateLearning_EndToEnd_RealDocToDtemplateToDocxAndPdf()
|
||||
{
|
||||
await RequireWord(); var source = @"C:\Users\codex\Desktop\НХС-33-УК3-ОЗХ-13011-000-НК-ОД.doc"; var content = @"C:\Users\codex\Desktop\НХС-33-УК3-ОЗХ-13011-000-НК-ОД.docx"; if (!File.Exists(source) || !File.Exists(content)) throw SkipException.ForSkip("The supplied real DOC/DOCX fixtures are not available.");
|
||||
var package = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".dtemplate"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var sourceHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(source))); var before = ProcessIds();
|
||||
try
|
||||
{
|
||||
using var normalizer = new WordDocumentNormalizer(); var learning = new TemplateLearningService(normalizer, new TemplatePackageStore(), new OpenXmlDocumentInspector()); var learned = await learning.LearnAsync(new TemplateLearningRequest { SourcePath = source, TemplateName = "Real sample", OutputPackagePath = package }, null, CancellationToken.None); Assert.NotEmpty(learned.Slots); Assert.True(File.Exists(package)); using var generator = new TemplateDocumentGenerator(new TemplatePackageStore(), new WordDocumentNormalizer(), new OpenXmlDocumentInspector()); var result = await generator.GenerateAsync(new TemplateGenerationRequest { TemplatePackagePath = package, ContentSourcePath = content, OutputDocxPath = output }, null, CancellationToken.None); Assert.True(File.Exists(result.OutputDocxPath)); Assert.True(File.Exists(result.OutputPdfPath)); Assert.Equal(sourceHash, Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(source)))); Assert.Empty(ProcessIds().Except(before));
|
||||
}
|
||||
finally { File.Delete(package); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".pdf")); File.Delete(Path.ChangeExtension(output, ".generation.json")); File.Delete(Path.ChangeExtension(output, ".validation.json")); }
|
||||
}
|
||||
|
||||
private static async Task RequireWord() { if (!OperatingSystem.IsWindows() || Type.GetTypeFromProgID("Word.Application", false) is null) throw SkipException.ForSkip("Microsoft Word COM is not installed or registered on this workstation."); await Task.CompletedTask; }
|
||||
private static async Task<WordDocumentResult> BuildTxt() { var template = CreateDocx(true); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); File.WriteAllText(body, "TXT integration body"); try { using var service = new WordDocumentService(); return await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); } finally { File.Delete(template); File.Delete(body); } }
|
||||
private static HashSet<int> ProcessIds() => Process.GetProcessesByName("WINWORD").Select(x => x.Id).ToHashSet();
|
||||
private static string ReadMainText(string path) { using var doc = WordprocessingDocument.Open(path, false); return doc.MainDocumentPart?.Document.InnerText ?? ""; }
|
||||
private static string CreateComplexSource()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = doc.AddMainDocumentPart(); var header1 = main.AddNewPart<HeaderPart>(); header1.Header = new Header(new Paragraph(new Run(new Text("SOURCE HEADER")))); var footer1 = main.AddNewPart<FooterPart>(); footer1.Footer = new Footer(new Paragraph(new Run(new Text("SOURCE FOOTER")))); var body = new Body(); var first = new Paragraph(new ParagraphProperties(new SectionProperties(new HeaderReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(header1) }, new FooterReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(footer1) })), new Run(new Text("DOCX complex body"))); body.Append(first); body.Append(new Table(new TableRow(new TableCell(new Paragraph(new Run(new Text("source table"))))))); body.Append(new SectionProperties(new HeaderReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(header1) }, new FooterReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(footer1) })); main.Document = new Document(body); main.Document.Save(); return path;
|
||||
}
|
||||
private static string CreateDocx(bool marker, string text = "template body") { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body(new Paragraph(new Run(new Text(marker ? "{{DOC_BODY}}" : text))))); main.Document.Save(); return path; }
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using DocxWorkbench.Core;
|
||||
using DocxWorkbench.OpenXml;
|
||||
|
||||
namespace DocxWorkbench.Tests;
|
||||
|
||||
public sealed class DocumentInspectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Analyze_DocxTemplateAndTxtBody_Succeeds()
|
||||
{
|
||||
var docx = CreateDocx(marker: true); var txt = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); File.WriteAllText(txt, "line one\nline two"); try { var template = new OpenXmlDocumentInspector().Inspect(docx); var body = new TextDocumentInspector().Inspect(txt); Assert.Equal("TextMarker", template.InsertionPoint.Type); Assert.Equal(2, body.Paragraphs); } finally { File.Delete(docx); File.Delete(txt); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_DocxTemplateAndDocxBody_Succeeds()
|
||||
{
|
||||
var template = CreateDocx(marker: true); var body = CreateDocx(marker: false); try { var inspector = new OpenXmlDocumentInspector(); Assert.Equal("TextMarker", inspector.Inspect(template).InsertionPoint.Type); Assert.Equal(1, inspector.Inspect(body).Sections); } finally { File.Delete(template); File.Delete(body); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_DetectsMarkerInsertionPoint()
|
||||
{
|
||||
var path = CreateDocx(marker: true); try { Assert.Equal("TextMarker", new OpenXmlDocumentInspector().Inspect(path).InsertionPoint.Type); } finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_FindsPageFieldsInFooter()
|
||||
{
|
||||
var path = CreateDocx(marker: true, fields: true); try { var result = new OpenXmlDocumentInspector().Inspect(path); Assert.Contains(result.Fields, field => field.Type == "PAGE" && field.Part.StartsWith("FooterPart", StringComparison.Ordinal)); Assert.Contains(result.Fields, field => field.Type == "NUMPAGES"); } finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_CountsHeaderFooterGraphics()
|
||||
{
|
||||
var path = CreateDocx(marker: true, graphics: true); try { var result = new OpenXmlDocumentInspector().Inspect(path); Assert.True(result.ImageSummary.RasterImages >= 2); } finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_UnsupportedFormatIsDomainError()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".bin"); File.WriteAllText(path, "not a document"); try { var exception = Assert.Throws<UnsupportedDocumentFormatException>(() => new OpenXmlDocumentInspector().Inspect(path)); Assert.Contains("Open XML inspector", exception.Message); } finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
private static string CreateDocx(bool marker, bool fields = false, bool graphics = false)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var document = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = document.AddMainDocumentPart(); var body = new Body(new Paragraph(new Run(new Text(marker ? "{{DOC_BODY}}" : "body")))); main.Document = new Document(body);
|
||||
var header = main.AddNewPart<HeaderPart>(); header.Header = new Header(new Paragraph(new Run(new Text("header")))); var footer = main.AddNewPart<FooterPart>();
|
||||
if (fields) { var paragraph = new Paragraph(); paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin })); paragraph.Append(new Run(new FieldCode(" PAGE "))); paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End })); paragraph.Append(new SimpleField { Instruction = " NUMPAGES " }); footer.Footer = new Footer(paragraph); } else footer.Footer = new Footer(new Paragraph(new Run(new Text("footer"))));
|
||||
if (graphics) { var image = header.AddImagePart(ImagePartType.Png); image.FeedData(new MemoryStream([137, 80, 78, 71])); var image2 = footer.AddImagePart(ImagePartType.Png); image2.FeedData(new MemoryStream([137, 80, 78, 71])); }
|
||||
body.Append(new SectionProperties(new HeaderReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(header) }, new FooterReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(footer) })); main.Document.Save(); return path;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup><ProjectReference Include="..\..\src\DocxWorkbench.Core\DocxWorkbench.Core.csproj" /><ProjectReference Include="..\..\src\DocxWorkbench.OpenXml\DocxWorkbench.OpenXml.csproj" /><ProjectReference Include="..\..\src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" /><PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" /></ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,22 +0,0 @@
|
||||
using DocxWorkbench.WordInterop;
|
||||
|
||||
namespace DocxWorkbench.Tests;
|
||||
|
||||
public sealed class StaWordQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Dispose_DrainsActiveJobAndIsIdempotent()
|
||||
{
|
||||
using var queue = new StaWordQueue(); using var started = new ManualResetEventSlim(false); using var release = new ManualResetEventSlim(false);
|
||||
var job = queue.Enqueue(() => { started.Set(); Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); release.Wait(); return 42; }, CancellationToken.None); Assert.True(started.Wait(TimeSpan.FromSeconds(2)));
|
||||
var dispose = Task.Run(queue.Dispose); var completed = await Task.WhenAny(dispose, Task.Delay(100)); Assert.NotSame(dispose, completed); release.Set(); await dispose; Assert.Equal(42, await job); queue.Dispose(); Assert.Throws<ObjectDisposedException>(() => EnqueueAfterDispose(queue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enqueue_CancellationCompletesCanceledTask()
|
||||
{
|
||||
using var queue = new StaWordQueue(); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queue.Enqueue(() => 1, cancellation.Token));
|
||||
}
|
||||
|
||||
private static void EnqueueAfterDispose(StaWordQueue queue) => queue.Enqueue(() => 1, CancellationToken.None);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.OpenXml;
|
||||
using DocxWorkbench.WordInterop;
|
||||
|
||||
namespace DocxWorkbench.Tests;
|
||||
|
||||
public sealed class TemplateLearningTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NormalizeDocx_UsesSafeWorkingCopy()
|
||||
{
|
||||
var source = CreateDocx("template"); var hash = Hash(source); var working = Path.Combine(Path.GetTempPath(), "learning-test-" + Guid.NewGuid().ToString("N")); using var normalizer = new WordDocumentNormalizer(); try { var result = await normalizer.NormalizeAsync(new DocumentNormalizationRequest { SourcePath = source, WorkingDirectory = working }, null, CancellationToken.None); Assert.False(result.WasConverted); Assert.NotEqual(Path.GetFullPath(source), Path.GetFullPath(result.NormalizedPath)); Assert.Equal(hash, Hash(source)); Assert.Equal(hash, result.SourceSha256); Assert.True(File.Exists(result.NormalizedPath)); } finally { File.Delete(source); if (Directory.Exists(working)) Directory.Delete(working, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplatePackage_CanBeCreatedAndReopened()
|
||||
{
|
||||
var source = CreateDocx("{{DOC_BODY}}"); var package = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".dtemplate"); using var normalizer = new WordDocumentNormalizer(); try { var service = new TemplateLearningService(normalizer, new TemplatePackageStore()); var learned = await service.LearnAsync(new TemplateLearningRequest { SourcePath = source, TemplateName = "Unit template", OutputPackagePath = package }, null, CancellationToken.None); Assert.NotEmpty(learned.Slots); Assert.Contains(learned.PageArchetypes, x => x.IsFirstPage); using var archive = ZipFile.OpenRead(package); Assert.Contains(archive.Entries, x => x.FullName == "manifest.json"); Assert.Contains(archive.Entries, x => x.FullName == "template.docx"); var opened = new TemplatePackageStore().Open(package); Assert.Equal(learned.TemplateId, opened.Template.TemplateId); Assert.True(File.Exists(opened.TemplateDocxPath)); Directory.Delete(opened.WorkingDirectory, true); } finally { File.Delete(source); File.Delete(package); }
|
||||
}
|
||||
|
||||
private static string CreateDocx(string text) { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var document = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = document.AddMainDocumentPart(); main.Document = new DocumentFormat.OpenXml.Wordprocessing.Document(new DocumentFormat.OpenXml.Wordprocessing.Body(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Text(text))))); main.Document.Save(); return path; }
|
||||
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.Tests;
|
||||
|
||||
public sealed class WorkbenchStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerifyButton_DisabledBeforeBuild() { var state = new WorkbenchStateController(); Assert.False(state.CanValidate); }
|
||||
[Fact]
|
||||
public void VerifyButton_DisabledAfterBuildFailure() { var state = Ready(); state.BeginBuild(); state.BuildFailed(); Assert.False(state.CanValidate); Assert.False(state.CanOpenDocx); }
|
||||
[Fact]
|
||||
public void VerifyButton_EnabledAfterBuildSuccess() { var state = Ready(); state.BeginBuild(); state.BuildSucceeded(); Assert.True(state.CanValidate); Assert.True(state.CanOpenDocx); Assert.True(state.CanOpenPdf); }
|
||||
private static WorkbenchStateController Ready() { var state = new WorkbenchStateController(); state.InputsSelected(true); state.AnalysisCompleted(true, true, true); return state; }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using DocumentAutomation.Shared;
|
||||
using StandardsWorkbench.Core;
|
||||
|
||||
namespace StandardsWorkbench.Tests;
|
||||
|
||||
public sealed class RulesEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void OnlyApprovedRulesAreEvaluated()
|
||||
{
|
||||
var document = new DocumentInspectionResult { FilePath = "synthetic.docx", ContentControls = 1, Sections = 1, Paragraphs = 2, Tables = 0 };
|
||||
var profile = new ApprovedRulesProfile { StandardId = "TEST", Code = "TEST", Revision = "1", Rules = [
|
||||
new CandidateRule { CandidateId = "approved", StandardId = "TEST", ClauseId = "1", SourceText = "x", Title = "x", RuleKind = "ContentControlPresence", Status = "Approved" },
|
||||
new CandidateRule { CandidateId = "new", StandardId = "TEST", ClauseId = "2", SourceText = "x", Title = "x", RuleKind = "SectionCount", Status = "New" }
|
||||
], SourceFileSha256 = "test"};
|
||||
var result = new RulesEngine().Evaluate(document, profile); Assert.Single(result); Assert.Equal(RuleEvaluationStatus.Pass, result[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedRuleHasClauseReference()
|
||||
{
|
||||
var document = new DocumentInspectionResult { FilePath = "synthetic.docx", ContentControls = 0, Sections = 1, Paragraphs = 1, Tables = 0 };
|
||||
var rule = new CandidateRule { CandidateId = "cc", StandardId = "TEST", ClauseId = "6.3", SourcePage = 4, SourceText = "required", Title = "cc", RuleKind = "ContentControlPresence", Status = "Tested" };
|
||||
var result = Assert.Single(new RulesEngine().Evaluate(document, new ApprovedRulesProfile { StandardId = "TEST", Code = "TEST", Revision = "1", SourceFileSha256 = "test", Rules = [rule] })); Assert.Equal(RuleEvaluationStatus.Fail, result.Status); Assert.Equal("6.3", result.ClauseId); Assert.Equal(4, result.SourcePage);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup><ProjectReference Include="..\..\src\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj" /></ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
artifacts/
|
||||
.local-dotnet-sdk/
|
||||
.local-tools/
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
@@ -0,0 +1,58 @@
|
||||
# AGENTS.md — P_Rec_Annot
|
||||
|
||||
## Назначение и package
|
||||
|
||||
`P_Rec_Annot` — приоритетный Plant 3D 2026 plugin для чтения, планирования и
|
||||
последующего создания Ortho-аннотаций; это перенос активного legacy поведения
|
||||
Plant 3D 2019 на .NET 8/Plant 3D 2026.
|
||||
|
||||
Единственная assembly для `NETLOAD` — `PRecAnnot.Plant3D2026.dll`.
|
||||
`PRecAnnot.*.dll` — internal managed dependencies, их нельзя NETLOAD по одной.
|
||||
Использовать Autodesk 2026 references; Autodesk DLL не включать в bundle.
|
||||
|
||||
## Commands и context
|
||||
|
||||
Поддерживаемые команды: `PHP_REC_ANNOT`, `PXP-AAV`, `PXP-OA`. Все
|
||||
регистрируются реальным Autodesk API, работают в Smoke/DryRun, не изменяют DWG
|
||||
в этих режимах и создают одно usage event на явный вызов.
|
||||
|
||||
Project status: `NotFound`, `FoundWithName`, `FoundWithoutDisplayName`,
|
||||
`Ambiguous`, `Failed`. Никогда не выводить `проект '' найден`; для пустого
|
||||
display name: `Plant 3D-проект открыт; отображаемое имя не определено.`
|
||||
Diagnostic code: `PLANT_PROJECT_DISPLAY_NAME_EMPTY`.
|
||||
|
||||
Drawing states: `Ortho`, `NotOrtho`, `Ambiguous`, `Failed`. Обычный DWG не
|
||||
классифицируется как Ortho; для реального Ortho применять Autodesk API и
|
||||
`OrthoDocUtils.IsValidOrthoDrawing`.
|
||||
|
||||
## DryRun и determinism
|
||||
|
||||
Pipeline: `Plant context → Ortho detection → sheet/view definitions → viewport
|
||||
snapshots → DataLinks → Plant properties → independent DTO → deterministic
|
||||
AnnotationPlan → diagnostics → no write`.
|
||||
|
||||
Autodesk objects, transactions и database handles остаются в adapter layer;
|
||||
Core/Application используют только независимые DTO. На неизменном fixture
|
||||
повторный DryRun обязан давать тот же PlanId, counts, порядок операций и warning
|
||||
codes, без side effects. PlanId не включает время, GUID, absolute path,
|
||||
нестабильный ObjectId или пользовательские данные.
|
||||
|
||||
## Write guard, Ribbon, tests
|
||||
|
||||
Все writes проходят централизованный write guard; в Smoke/DryRun он всегда
|
||||
запрещает изменения. Apply не обходится через command handler/adapter.
|
||||
|
||||
Ожидаемый Ribbon путь: `Промхимпроект → Аннотации → Аннотировать`. Требуются
|
||||
stable IDs, отсутствие дублей, binding к `PHP_REC_ANNOT`, устойчивость к
|
||||
документам/workspace и корректный Terminate.
|
||||
|
||||
Unit tests не требуют Autodesk runtime. Runtime evidence отдельно фиксирует
|
||||
initialization, commands, context, Ortho, DataLinks, plan, Ribbon, DBMOD,
|
||||
telemetry и shutdown.
|
||||
|
||||
Bundle содержит только фактический runtime dependency graph и никогда не
|
||||
содержит Autodesk DLL, PDB, test assemblies, legacy 2019 assemblies, bin/obj
|
||||
или production configuration.
|
||||
|
||||
Fixture: local Plant 3D 2026 project вне Git с настоящей Pipe, synthetic line
|
||||
number, Ortho drawing/viewport и SHA baseline до DryRun.
|
||||
@@ -1,13 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Deterministic>true</Deterministic>
|
||||
<DebugType>portable</DebugType>
|
||||
<AutoCAD2026ManagedDir Condition="'$(AutoCAD2026ManagedDir)' == '' and '$(Plant3D2026InstallDir)' != ''">$(Plant3D2026InstallDir)</AutoCAD2026ManagedDir>
|
||||
<Plant3D2026ManagedDir Condition="'$(Plant3D2026ManagedDir)' == '' and '$(Plant3D2026InstallDir)' != ''">$(Plant3D2026InstallDir)\PLNT3D</Plant3D2026ManagedDir>
|
||||
<Plant3D2026InstallDir Condition="'$(Plant3D2026InstallDir)' == '' and '$(AutoCAD2026ManagedDir)' != ''">$(AutoCAD2026ManagedDir)</Plant3D2026InstallDir>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,54 +0,0 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Abstractions", "src\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj", "{10000000-0000-0000-0000-000000000001}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Core", "src\PRecAnnot.Core\PRecAnnot.Core.csproj", "{10000000-0000-0000-0000-000000000002}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Application", "src\PRecAnnot.Application\PRecAnnot.Application.csproj", "{10000000-0000-0000-0000-000000000003}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Plant3D2026", "src\PRecAnnot.Plant3D2026\PRecAnnot.Plant3D2026.csproj", "{10000000-0000-0000-0000-000000000004}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.UI", "src\PRecAnnot.UI\PRecAnnot.UI.csproj", "{10000000-0000-0000-0000-000000000005}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Telemetry", "src\PRecAnnot.Telemetry\PRecAnnot.Telemetry.csproj", "{10000000-0000-0000-0000-000000000006}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Core.Tests", "tests\PRecAnnot.Core.Tests\PRecAnnot.Core.Tests.csproj", "{10000000-0000-0000-0000-000000000007}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{10000000-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000006}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000006}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000006}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10000000-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10000000-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,24 +0,0 @@
|
||||
# P_Rec_Annot Plant 3D 2026 foundation
|
||||
|
||||
Foundation for migration from the legacy P_Rec_Annot v1.0.4. Legacy sources remain read-only and are not copied here.
|
||||
|
||||
Current slice:
|
||||
|
||||
* `PRecAnnot.Abstractions` — Autodesk-free immutable contracts and DTOs;
|
||||
* `PRecAnnot.Core` — deterministic annotation planning and duplicate filtering;
|
||||
* `PRecAnnot.Application` — command registry and use-case orchestration;
|
||||
* `PRecAnnot.Plant3D2026` — guarded adapter boundary; compilation against Plant 3D 2026 is blocked until the SDK/runtime is installed;
|
||||
* `PRecAnnot.UI` — idempotent Ribbon registration model, without legacy `_netload`;
|
||||
* `PRecAnnot.Telemetry` — local JSONL command-scope events, without project path/number;
|
||||
* `PRecAnnot.Bundle.Tests` and `PRecAnnot.Core.Tests` — architecture/core/packaging checks.
|
||||
|
||||
The first vertical slice is intentionally a dry-run planning slice. It does not mutate DWG until the Plant 3D 2026 adapter is compiled and runtime-tested on a controlled project.
|
||||
|
||||
Build:
|
||||
|
||||
```powershell
|
||||
dotnet build .\PRecAnnot2026.sln --configuration Release
|
||||
dotnet test .\PRecAnnot2026.sln --configuration Release --no-build
|
||||
```
|
||||
|
||||
When Plant 3D 2026 is installed, pass `/p:Plant3D2026InstallDir=...`. Autodesk assemblies are `Private=false`, `CopyLocal=false` and are never committed.
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ApplicationPackage SchemaVersion="1.0" Name="P_Rec_Annot" Description="P_Rec_Annot Plant 3D 2026" AppVersion="0.1.0" Author="NBIM">
|
||||
<Components>
|
||||
<!-- SeriesMin/SeriesMax and platform token are pending official/runtime verification. -->
|
||||
<RuntimeRequirements OS="Win64" />
|
||||
<ComponentEntry AppName="PRecAnnot2026" ModuleName="Contents\Windows\PRecAnnot.Plant3D2026.dll" AppDescription="P_Rec_Annot entry assembly" LoadReasons="LoadOnAutoCADStartup" />
|
||||
</Components>
|
||||
</ApplicationPackage>
|
||||
@@ -1,3 +0,0 @@
|
||||
# Target architecture
|
||||
|
||||
Core and Abstractions have no Autodesk, WinForms or WPF references. Only `PRecAnnot.Plant3D2026` may reference installed Autodesk assemblies. DWG mutation remains synchronous on AutoCAD's thread; no `Task.Run` is used for database operations. Ribbon registration is guarded and unloadable. The bundle has one entry assembly and no legacy `_netload` chain.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Build
|
||||
|
||||
The workstation has .NET SDK 8.0.423 and Autodesk AutoCAD Plant 3D 2026 installed. The paths are deliberately supplied at build time; no machine-specific Autodesk path is stored in project files.
|
||||
|
||||
Detected assembly inventory:
|
||||
|
||||
| Assembly | Assembly version | Build input |
|
||||
|---|---:|---|
|
||||
| AcMgd.dll | 25.1.0.0 | `AutoCAD2026ManagedDir` |
|
||||
| AcDbMgd.dll | 25.1.0.0 | `AutoCAD2026ManagedDir` |
|
||||
| AcCoreMgd.dll | 25.1.0.0 | `AutoCAD2026ManagedDir` |
|
||||
| AdWindows.dll | 5.2.1.1 | `AutoCAD2026ManagedDir` |
|
||||
| PnP3dObjectsMgd.dll | 17.0.60.1 | `Plant3D2026ManagedDir` |
|
||||
| PnPProjectManagerMgd.dll | 17.0.60.1 | `Plant3D2026ManagedDir` |
|
||||
|
||||
The actual build used:
|
||||
|
||||
```powershell
|
||||
dotnet restore .\Plant3D2026\P_Rec_Annot\PRecAnnot2026.sln
|
||||
dotnet build .\Plant3D2026\P_Rec_Annot\PRecAnnot2026.sln --configuration Release `
|
||||
/p:AutoCAD2026ManagedDir="<AutoCAD managed directory>" `
|
||||
/p:Plant3D2026ManagedDir="<Plant 3D managed directory>"
|
||||
```
|
||||
|
||||
`PRecAnnot.Plant3D2026` now compiles against the real AutoCAD/Plant 3D 2026 metadata. The adapter has real `IExtensionApplication`, `CommandMethod`, `PlantApplication.CurrentProject`, `Document`, `Editor`, and `Autodesk.Windows` Ribbon references. The previous conditional foundation-only adapter was removed. Autodesk references are `Private=false` and `CopyLocal=false`.
|
||||
|
||||
Release emits one non-fatal WindowsBase reference conflict caused by Autodesk's legacy reference surface versus the .NET 8 reference pack. It does not copy Autodesk assemblies to output. Autodesk DLLs are excluded from Git, NuGet, bundle, and publish output.
|
||||
|
||||
The local smoke bundle is created at `Plant3D2026\P_Rec_Annot\artifacts\P_Rec_Annot.bundle`. Deploy is NOT PERFORMED.
|
||||
@@ -1,18 +0,0 @@
|
||||
# Command migration matrix
|
||||
|
||||
Initial active set is based on non-archive command registrations and Ribbon definitions in audit evidence. This matrix is deliberately explicit; commands remain blocked until Plant 3D 2026 runtime evidence exists.
|
||||
|
||||
| Legacy command | Legacy module | Target use case | Decision | Status | Blocker/test |
|
||||
|---|---|---|---|---|---|
|
||||
| PXP-AAV | AutoAnnotateView | automatic annotations in viewport | port after isolation | BLOCKED_WITH_REASON | 2026 assemblies/runtime |
|
||||
| PXP-OA | OrthoAutoAnnotate | axis/support graphical annotations | port after isolation | BLOCKED_WITH_REASON | exact legacy rules/golden DWG |
|
||||
| PXP-ALC | AutoLinenumberConnections | line-number connection annotations | reimplement | BLOCKED_WITH_REASON | DataLinks adapter |
|
||||
| PXP-LN, PXP-ELN, PXP-ULN, PXP-ESN, PXP-RMP | AnnotateLineNumbers | line-number list/updates | reimplement | BLOCKED_WITH_REASON | golden cases |
|
||||
| PXP-AE, PXP-IE | AutoElevation/InsertElevation | elevations | reimplement | BLOCKED_WITH_REASON | geometry/runtime |
|
||||
| PXP-IOD | UpdateOrthoDrawings | batch update | defer to Wave 5 | BLOCKED_WITH_REASON | multi-DWG transaction |
|
||||
| PXP-UOB | UpdateOrthoViewBlocks | viewport layers | adapter | BLOCKED_WITH_REASON | layer API |
|
||||
| PXP-OSI, PXP-COV | OrthoAutoAnnotate/CheckOrthoView | locate/check objects | compare native | BLOCKED_WITH_REASON | runtime comparison |
|
||||
| PXP-MA, CAC, BJ, mto-ma | PHPAnnotations/P3DNewMa | manual annotation blocks | port/review | BLOCKED_WITH_REASON | UI and block contract |
|
||||
| PXP-AC | SwapAttributesMenuItem | swap annotation attributes | port after rules | BLOCKED_WITH_REASON | block schema |
|
||||
|
||||
Archive-only registrations are not published. No command is silently discarded.
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"executionMode": "DryRun",
|
||||
"runtimeExecuted": false,
|
||||
"plantProjectDetected": null,
|
||||
"orthoDetection": "NotExecuted",
|
||||
"viewportCount": null,
|
||||
"dataLinksAvailable": null,
|
||||
"objectsRead": null,
|
||||
"plan": {
|
||||
"create": null,
|
||||
"update": null,
|
||||
"skip": null,
|
||||
"conflict": null,
|
||||
"unsupported": null
|
||||
},
|
||||
"planDeterministic": true,
|
||||
"dbmodBefore": null,
|
||||
"dbmodAfter": null,
|
||||
"dwgModified": null,
|
||||
"dcfModified": null,
|
||||
"projectXmlModified": null,
|
||||
"unhandledExceptions": null,
|
||||
"blocker": "No controlled test Ortho DWG/project path was supplied or discovered; runtime DryRun was not claimed."
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"product": "Autodesk AutoCAD Plant 3D 2026",
|
||||
"manualNetload": true,
|
||||
"bundleLoaded": true,
|
||||
"automaticAppLoaderChecked": false,
|
||||
"smokeModeConfirmedByOperator": true,
|
||||
"observedCommandLogRuntimeMode": "Normal",
|
||||
"assemblyInitializeCountObserved": 1,
|
||||
"commands": {
|
||||
"PHP_REC_ANNOT": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" },
|
||||
"PXP-AAV": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" },
|
||||
"PXP-OA": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" }
|
||||
},
|
||||
"ribbon": { "created": true, "duplicateCheckedExhaustively": false },
|
||||
"plantContext": { "projectDetected": true, "projectDisplayName": "DefaultProject", "observedOrthoDetected": false, "dataLinksAvailable": true },
|
||||
"writesBlocked": true,
|
||||
"dbmodChecked": false,
|
||||
"dwgModified": false,
|
||||
"dcfModified": false,
|
||||
"projectXmlModified": false,
|
||||
"telemetryEventsExpected": 3,
|
||||
"telemetryEventsActual": 3,
|
||||
"unhandledExceptions": 0,
|
||||
"temporaryInstallationRemoved": true,
|
||||
"productionDeploy": false
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Golden test plan
|
||||
|
||||
Capture a minimal 2019 Ortho fixture with input objects, output blocks/attributes, text, layer, style, coordinates, rotation, scale, DataLinks, before/after usage event and repeat-run behavior. Recreate the same fixture in 2026 only after the adapter is available. Do not commit production DWG/DCF.
|
||||
@@ -1,5 +0,0 @@
|
||||
RuleId,TargetClass,TargetMethod,LegacyFile,LegacyLines,Behavior,MigrationDecision,TestName
|
||||
RULE-001,AnnotationRuleEngine,BuildPlan,AutoAnnotateView/Commands.cs,476-500,Remove prior annotations then insert calculated annotations,REIMPLEMENT_SAME_BEHAVIOR,ExistingAnnotation_IsUpdatedNotDuplicated
|
||||
RULE-002,AnnotationRuleEngine,BuildPlan,OrthoAutoAnnotate/Commands.cs,6444-6488,Require Plant project and registered Ortho drawing,KEEP_AFTER_ISOLATION,PlantProjectAndOrthoDrawingRequired
|
||||
RULE-003,PlantObjectReader,ReadObjects,README.md,96-101,Read EngineeringItems and line-group relationships through PnP API,REIMPLEMENT_SAME_BEHAVIOR,PlantAdapter_ReadsGoldenObjects
|
||||
RULE-004,CommandRegistry,Foundation,OrthoLoader/Ribbon.cs,109-129,Expose annotation commands in annotation panel,REIMPLEMENT_SAME_BEHAVIOR,AllActiveCommands_AreMapped
|
||||
|
@@ -1,18 +0,0 @@
|
||||
# Migration dashboard
|
||||
|
||||
The first real Plant 3D 2026 build is complete against the workstation assemblies (AutoCAD managed 25.1.0.0; Plant managed 17.0.60.1; AdWindows 5.2.1.1). Core remains Autodesk-free.
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
| `PHP_REC_ANNOT` | Registered real `CommandMethod`; primary diagnostic entry point |
|
||||
| `PXP-AAV` | Registered real `CommandMethod`; diagnostic alias |
|
||||
| `PXP-OA` | Registered real `CommandMethod`; diagnostic alias |
|
||||
| Ribbon | Implemented with `Autodesk.Windows`; idempotent tab/panel guard |
|
||||
| Adapter | Compiled against real 2026 assemblies |
|
||||
| Bundle | Local smoke package built and checked for Autodesk DLL exclusion |
|
||||
| Runtime smoke | Manual NETLOAD confirmed on Plant 3D 2026; automatic AppLoader and Ortho DryRun remain unverified |
|
||||
| Ortho read path | Compiled against `OrthoDocUtils`, `OrthoProject`, `DwgSheetDefinitions`, `DwgViewDefs`, and `DataLinksManager`; runtime fixture pending |
|
||||
| Dry-run plan | Deterministic Core builder with DTO boundary and Apply guard; no annotation writes |
|
||||
| Production deploy | NOT PERFORMED |
|
||||
|
||||
Fixed API assumptions: foundation-only lifecycle became `IExtensionApplication`; command descriptors became actual `CommandMethod` registrations; Ortho detection uses `OrthoDocUtils.IsValidOrthoDrawing` plus the Ortho project part; viewport metadata uses `DwgSheetDefinitions`/`DwgViewDefs`; object properties use read-only `DataLinksManager.SelectAcPpRowIds` and `GetAllProperties`; Core receives only immutable DTOs. Remaining blockers are a controlled Ortho fixture, geometry/object-to-viewport mapping, legacy source provenance, and future Apply implementation.
|
||||
@@ -1,15 +0,0 @@
|
||||
# Module migration matrix
|
||||
|
||||
| Legacy module | Target project | Classification | Wave |
|
||||
|---|---|---|---|
|
||||
| AnnotateLineNumbers | PRecAnnot.Core + Plant3D2026 | KEEP_AFTER_ISOLATION | 3 |
|
||||
| AutoAnnotateView | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 1 |
|
||||
| OrthoAutoAnnotate | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 1 |
|
||||
| AutoLinenumberConnections | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 2 |
|
||||
| AutoElevation/InsertElevation | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 3 |
|
||||
| UpdateOrthoDrawings | Plant3D2026/Application | DEFER_AND_RETEST | 5 |
|
||||
| OrthoLoader | bundle/entry composition root | DO_NOT_REUSE | 1 |
|
||||
| Ribbon | PRecAnnot.UI | REIMPLEMENT | 1 |
|
||||
| GetLinenumberAnnotationText | Plant adapter + Core mapper | KEEP_AFTER_ISOLATION | 2 |
|
||||
| PHPAnnotations/P3DNewMa | UI + block adapter | REVIEW_USAGE | 4 |
|
||||
| telemetry | PRecAnnot.Telemetry | REIMPLEMENT | 1 |
|
||||
@@ -1,9 +0,0 @@
|
||||
# PXP-AAV read-path analysis
|
||||
|
||||
Status: traced to the existing command registration and the shared dry-run plan path; annotation writes are not implemented in this slice.
|
||||
|
||||
| RuleId | LegacyCommand | LegacyProject | LegacyFile | ObservedBehavior | TargetUseCase | TargetClass | Test | Status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| PXP-AAV-001 | PXP-AAV | legacy audit | `docs/legacy-rule-provenance.csv` | legacy provenance is recorded, but the requested legacy checkout is absent on this workstation | read objects visible to an Ortho view and build a dry-run plan | adapter DTO -> `AnnotationPlan` | `AnnotationPlan_InputOrderDoesNotChangeResult` | partial |
|
||||
|
||||
The implementation deliberately does not claim behavioral equivalence. The next required input is a read-only copy of the legacy source or a golden test Ortho drawing with expected diagnostics.
|
||||
@@ -1,9 +0,0 @@
|
||||
# PXP-OA read-path analysis
|
||||
|
||||
Status: command is a real `CommandMethod` and uses the same safe context/read-plan pipeline. No Ortho annotation is created, updated, or deleted.
|
||||
|
||||
| RuleId | LegacyCommand | LegacyProject | LegacyFile | ObservedBehavior | TargetUseCase | TargetClass | Test | Status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| PXP-OA-001 | PXP-OA | legacy audit | `docs/legacy-rule-provenance.csv` | legacy provenance is available, but source checkout was not found locally | Ortho context and DataLinks read diagnostics | adapter DTO -> `AnnotationPlan` | `ApplyMode_IsBlocked` | partial |
|
||||
|
||||
Exact object filters, annotation text, placement rules, and existing-annotation detection remain unconfirmed until the legacy source or a controlled golden fixture is supplied.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Plant 3D 2026 runtime smoke results
|
||||
|
||||
## Scope
|
||||
|
||||
The smoke test used a temporary per-user bundle installation only. Production deploy was not performed. The temporary installation was removed after AutoCAD closed.
|
||||
|
||||
## Confirmed
|
||||
|
||||
- Manual `NETLOAD` loaded the P_Rec_Annot entry assembly in AutoCAD Plant 3D 2026.
|
||||
- `PHP_REC_ANNOT`, `PXP-AAV`, and `PXP-OA` were accepted by the command line.
|
||||
- The active Plant project was detected as `DefaultProject`.
|
||||
- The user confirmed Smoke mode behavior and no DWG mutation during the manual smoke interaction.
|
||||
- The runtime log contains one assembly initialization and one normal termination for the observed process.
|
||||
- The runtime log records Ribbon creation with status `Created`.
|
||||
- The runtime log records exactly one `CommandUsageEvent` for each of the three commands.
|
||||
- The observed Plant context reported `DataLinksAvailable=true` and `IsOrthoDrawing=false` for `Floor Plan Sample.dwg`.
|
||||
- No unhandled exception was reported in the captured runtime log.
|
||||
|
||||
## Not confirmed
|
||||
|
||||
- Automatic AppLoader loading without `NETLOAD` was not tested.
|
||||
- DBMOD and byte-level DWG/DCF/Project.xml comparisons were not captured by the smoke evidence.
|
||||
- Ribbon duplicate behavior across workspace/document lifecycle was not exhaustively tested.
|
||||
- A command invocation logged as `RuntimeMode=Smoke` is not present in the captured JSONL; the three detailed command records have `RuntimeMode=Normal`.
|
||||
|
||||
## Evidence handling
|
||||
|
||||
The detailed runtime JSONL remains local under `%LOCALAPPDATA%\Promhimproekt\PRecAnnot2026\Logs\` and is not committed. No DWG, DCF, Project.xml, Autodesk DLL, user identity, project path, or full local runtime path is included in the repository.
|
||||
|
||||
Temporary installation: PERFORMED AND REMOVED.
|
||||
Production deploy: NOT PERFORMED.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Runtime test plan
|
||||
|
||||
Runtime smoke is ready but has not been executed in AutoCAD. The local package is `Plant3D2026\P_Rec_Annot\artifacts\P_Rec_Annot.bundle`.
|
||||
|
||||
Next-step installation command (intentionally not executed):
|
||||
|
||||
```powershell
|
||||
Copy-Item -Recurse -Force .\Plant3D2026\P_Rec_Annot\artifacts\P_Rec_Annot.bundle `
|
||||
"$env:APPDATA\Autodesk\ApplicationPlugins\P_Rec_Annot.bundle"
|
||||
```
|
||||
|
||||
In a controlled Plant 3D 2026 session, verify that the entry assembly loads, the `Промхимпроект` tab and `Аннотации` panel occur once, and the `Аннотировать` button invokes `PHP_REC_ANNOT`. Open a Plant project and active DWG, run all three commands, verify the project guard and diagnostic-only behavior, and confirm no unhandled exception. Repeat initialization and confirm no Ribbon duplicate. DWG mutation, Ortho viewport/data-link behavior, undo, repeat-run idempotence, and golden-DWG comparison remain runtime blockers for the implementation wave.
|
||||
|
||||
Deploy status: NOT PERFORMED.
|
||||
# Current slice: Ortho read / dry-run
|
||||
|
||||
`P_REC_ANNOT_RUNTIME_MODE=Smoke` reads context only. `DryRun` reads Ortho view metadata and DataLinks properties, builds an immutable deterministic `AnnotationPlan`, and never applies it. `Apply` is blocked with `APPLY_MODE_NOT_IMPLEMENTED`; unknown values resolve to `DryRun`.
|
||||
|
||||
The read adapter uses `OrthoDocUtils.IsValidOrthoDrawing`, `OrthoProject.DwgSheetDefinitions`, `IDwgViewDef`, and read-only `DataLinksManager` methods. The adapter does not expose AutoCAD or Plant objects to Core and contains no write calls. A runtime DryRun result is not published until a controlled test Ortho DWG is available; see `docs/evidence/ortho-dry-run-summary.json`.
|
||||
@@ -1,212 +0,0 @@
|
||||
namespace PRecAnnot.Abstractions;
|
||||
|
||||
public sealed record RuntimeDiagnostic
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string Severity { get; init; } = "Warning";
|
||||
}
|
||||
|
||||
public enum OrthoDetectionStatus { Confirmed, NotOrtho, Ambiguous, Unsupported, Failed }
|
||||
|
||||
public sealed record OrthoDetectionResult
|
||||
{
|
||||
public required OrthoDetectionStatus Status { get; init; }
|
||||
public required IReadOnlyList<string> Evidence { get; init; }
|
||||
public required IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct Point2Dto(double X, double Y);
|
||||
public readonly record struct Point3Dto(double X, double Y, double Z);
|
||||
public readonly record struct Vector2Dto(double X, double Y);
|
||||
public sealed record Bounds2Dto(Point2Dto Minimum, Point2Dto Maximum);
|
||||
|
||||
public sealed record GeometrySnapshot
|
||||
{
|
||||
public Point3Dto? WorldPosition { get; init; }
|
||||
public Point2Dto? PaperPosition { get; init; }
|
||||
public Bounds2Dto? ProjectedBounds { get; init; }
|
||||
public double? Rotation { get; init; }
|
||||
public double? Elevation { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OrthoViewportInfo
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public double CenterX { get; init; }
|
||||
public double CenterY { get; init; }
|
||||
public double Width { get; init; }
|
||||
public double Height { get; init; }
|
||||
public double Scale { get; init; }
|
||||
public double TwistAngle { get; init; }
|
||||
public bool IsOn { get; init; }
|
||||
public bool IsLocked { get; init; }
|
||||
public string? SourceDrawingName { get; init; }
|
||||
public string? ViewName { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlantObjectSnapshot
|
||||
{
|
||||
public required string SnapshotId { get; init; }
|
||||
public required string ObjectClass { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public string? LineNumber { get; init; }
|
||||
public string? SupportNumber { get; init; }
|
||||
public string? EquipmentNumber { get; init; }
|
||||
public required GeometrySnapshot Geometry { get; init; }
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>();
|
||||
public IReadOnlyList<string> Diagnostics { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed record PlantRuntimeContext
|
||||
{
|
||||
public bool HasActiveDocument { get; init; }
|
||||
public bool HasPlantProject { get; init; }
|
||||
public bool IsOrthoDrawing { get; init; }
|
||||
public string? DrawingName { get; init; }
|
||||
public string? ProjectDisplayName { get; init; }
|
||||
public IReadOnlyList<OrthoViewportInfo> Viewports { get; init; } = Array.Empty<OrthoViewportInfo>();
|
||||
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
|
||||
public OrthoDetectionResult Detection { get; init; } = new() { Status = OrthoDetectionStatus.Unsupported, Evidence = Array.Empty<string>(), Diagnostics = Array.Empty<RuntimeDiagnostic>() };
|
||||
}
|
||||
|
||||
public interface IPlantRuntimeContextReader { PlantRuntimeContext ReadCurrentContext(); }
|
||||
public interface IOrthoViewportReader { IReadOnlyList<OrthoViewportInfo> ReadViewports(CancellationToken cancellationToken); }
|
||||
|
||||
public enum AnnotationOperationKind { Create, Update, Skip, Conflict, Unsupported }
|
||||
|
||||
public sealed record AnnotationOperationPlan
|
||||
{
|
||||
public required string SourceSnapshotId { get; init; }
|
||||
public required AnnotationOperationKind Kind { get; init; }
|
||||
public string? AnnotationType { get; init; }
|
||||
public string? Text { get; init; }
|
||||
public Point2Dto? Position { get; init; }
|
||||
public double? Rotation { get; init; }
|
||||
public string? ExistingAnnotationId { get; init; }
|
||||
public required IReadOnlyList<string> Reasons { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AnnotationPlanSummary
|
||||
{
|
||||
public int ObjectsExamined { get; init; }
|
||||
public int CreateCount { get; init; }
|
||||
public int UpdateCount { get; init; }
|
||||
public int SkipCount { get; init; }
|
||||
public int ConflictCount { get; init; }
|
||||
public int UnsupportedCount { get; init; }
|
||||
public int WarningCount { get; init; }
|
||||
public int ErrorCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AnnotationPlanningRequest
|
||||
{
|
||||
public required string CommandName { get; init; }
|
||||
public required OrthoViewportInfo Viewport { get; init; }
|
||||
public IReadOnlyList<PlantObjectSnapshot> Objects { get; init; } = Array.Empty<PlantObjectSnapshot>();
|
||||
public IReadOnlyList<ExistingAnnotation> Existing { get; init; } = Array.Empty<ExistingAnnotation>();
|
||||
}
|
||||
|
||||
public interface IAnnotationPlanBuilder { AnnotationPlan Build(AnnotationPlanningRequest request); }
|
||||
public enum PRecAnnotExecutionMode { Smoke, DryRun, Apply }
|
||||
public interface IRuntimeWriteGuard { void EnsureWriteAllowed(string operation); }
|
||||
|
||||
public sealed record OrthoViewportRef(string DrawingId, string ViewportId);
|
||||
|
||||
public sealed record PlantObjectData
|
||||
{
|
||||
public required string ObjectId { get; init; }
|
||||
public required string ClassName { get; init; }
|
||||
public string? LineNumber { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public required AnnotationPoint Position { get; init; }
|
||||
public double Rotation { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct AnnotationPoint(double X, double Y, double Z = 0);
|
||||
|
||||
public sealed record ExistingAnnotation(string StableKey, string? Text, AnnotationPoint Position);
|
||||
|
||||
public sealed record AnnotationRequest
|
||||
{
|
||||
public required OrthoViewportRef Viewport { get; init; }
|
||||
public required IReadOnlyList<PlantObjectData> Objects { get; init; }
|
||||
public required IReadOnlyList<ExistingAnnotation> Existing { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AnnotationIntent
|
||||
{
|
||||
public required string StableKey { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public required AnnotationPoint Position { get; init; }
|
||||
public required double Rotation { get; init; }
|
||||
public required string ClassName { get; init; }
|
||||
public required string Layer { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AnnotationPlan
|
||||
{
|
||||
public string PlanId { get; init; } = string.Empty;
|
||||
public string CommandName { get; init; } = string.Empty;
|
||||
public IReadOnlyList<AnnotationOperationPlan> Operations { get; init; } = Array.Empty<AnnotationOperationPlan>();
|
||||
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
|
||||
public AnnotationPlanSummary Summary { get; init; } = new();
|
||||
public required IReadOnlyList<AnnotationIntent> Create { get; init; }
|
||||
public required IReadOnlyList<AnnotationIntent> Update { get; init; }
|
||||
public required IReadOnlyList<string> Delete { get; init; }
|
||||
public required IReadOnlyList<string> SkipReasons { get; init; }
|
||||
public int ExaminedCount { get; init; }
|
||||
public bool IsDeterministic { get; init; } = true;
|
||||
}
|
||||
|
||||
public interface IPlantProjectContext
|
||||
{
|
||||
bool IsPlantProjectOpen { get; }
|
||||
string? ProjectName { get; }
|
||||
}
|
||||
|
||||
public interface IOrthoDrawingContext
|
||||
{
|
||||
bool IsOrthoDrawing { get; }
|
||||
IReadOnlyList<OrthoViewportRef> GetViewports();
|
||||
}
|
||||
|
||||
public interface IPlantObjectReader
|
||||
{
|
||||
IReadOnlyList<PlantObjectSnapshot> ReadObjects(OrthoViewportInfo viewport, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IAnnotationRepository
|
||||
{
|
||||
IReadOnlyList<ExistingAnnotation> FindExisting(OrthoViewportRef viewport);
|
||||
AnnotationWriteResult Apply(AnnotationPlan plan, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AnnotationWriteResult(int Created, int Updated, int Deleted, int Skipped);
|
||||
|
||||
public interface IAnnotationRuleEngine
|
||||
{
|
||||
AnnotationPlan BuildPlan(AnnotationRequest request);
|
||||
}
|
||||
|
||||
public sealed record CommandUsageEvent
|
||||
{
|
||||
public required string CommandName { get; init; }
|
||||
public required string ResultStatus { get; init; }
|
||||
public int ObjectsExamined { get; init; }
|
||||
public int AnnotationsCreated { get; init; }
|
||||
public int AnnotationsUpdated { get; init; }
|
||||
public int AnnotationsDeleted { get; init; }
|
||||
public int AnnotationsSkipped { get; init; }
|
||||
public int WarningCount { get; init; }
|
||||
public int ErrorCount { get; init; }
|
||||
public long DurationMilliseconds { get; init; }
|
||||
public DateTimeOffset TimestampUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public interface ICommandUsageSink
|
||||
{
|
||||
void Record(CommandUsageEvent usageEvent);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,36 +0,0 @@
|
||||
namespace PRecAnnot.Application;
|
||||
|
||||
public sealed record PRecAnnotCommandDescriptor
|
||||
{
|
||||
public required string CommandName { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public required string UseCaseId { get; init; }
|
||||
public required string RibbonPanel { get; init; }
|
||||
public bool RequiresPlantProject { get; init; }
|
||||
public bool RequiresOrthoDrawing { get; init; }
|
||||
public bool RequiresSelection { get; init; }
|
||||
public string Status { get; init; } = "BLOCKED_WITH_REASON";
|
||||
}
|
||||
|
||||
public sealed class CommandRegistry
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, PRecAnnotCommandDescriptor> _commands;
|
||||
|
||||
public CommandRegistry(IEnumerable<PRecAnnotCommandDescriptor> commands)
|
||||
{
|
||||
var list = commands.ToList();
|
||||
if (list.GroupBy(x => x.CommandName, StringComparer.OrdinalIgnoreCase).Any(x => x.Count() > 1))
|
||||
throw new ArgumentException("Command names must be unique.", nameof(commands));
|
||||
_commands = list.ToDictionary(x => x.CommandName, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<PRecAnnotCommandDescriptor> Commands => _commands.Values.ToArray();
|
||||
public bool TryGet(string name, out PRecAnnotCommandDescriptor? descriptor) => _commands.TryGetValue(name, out descriptor);
|
||||
|
||||
public static CommandRegistry Foundation() => new(new[]
|
||||
{
|
||||
new PRecAnnotCommandDescriptor { CommandName = "PHP_REC_ANNOT", DisplayName = "Аннотировать", UseCaseId = "ortho-annotation", RibbonPanel = "Аннотации", RequiresPlantProject = true, RequiresOrthoDrawing = true, Status = "BLOCKED_WITH_REASON" },
|
||||
new PRecAnnotCommandDescriptor { CommandName = "PXP-AAV", DisplayName = "Разместить аннотации автоматически", UseCaseId = "auto-annotate-view", RibbonPanel = "Аннотации", RequiresPlantProject = true, RequiresOrthoDrawing = true, Status = "BLOCKED_WITH_REASON" },
|
||||
new PRecAnnotCommandDescriptor { CommandName = "PXP-OA", DisplayName = "Разместить осевые линии и опоры", UseCaseId = "ortho-axes-supports", RibbonPanel = "Осевые линии и опоры", RequiresPlantProject = true, RequiresOrthoDrawing = true, Status = "BLOCKED_WITH_REASON" }
|
||||
});
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -1,109 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using PRecAnnot.Abstractions;
|
||||
|
||||
namespace PRecAnnot.Core;
|
||||
|
||||
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
|
||||
{
|
||||
public AnnotationPlan Build(AnnotationPlanningRequest request)
|
||||
{
|
||||
var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
|
||||
var existing = request.Existing.ToDictionary(x => x.StableKey, StringComparer.OrdinalIgnoreCase);
|
||||
var operations = new List<AnnotationOperationPlan>(objects.Length);
|
||||
var creates = new List<AnnotationIntent>();
|
||||
var updates = new List<AnnotationIntent>();
|
||||
var diagnostics = new List<RuntimeDiagnostic>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var item in objects)
|
||||
{
|
||||
if (!seen.Add(item.SnapshotId))
|
||||
{
|
||||
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Conflict, Reasons = new[] { "duplicate-snapshot-id" } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(item.ObjectClass))
|
||||
{
|
||||
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Unsupported, Reasons = new[] { "missing-object-class" } });
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "missing-line-number-or-tag" } });
|
||||
continue;
|
||||
}
|
||||
|
||||
var position = item.Geometry.PaperPosition;
|
||||
if (position is null)
|
||||
{
|
||||
diagnostics.Add(new RuntimeDiagnostic { Code = "MISSING_GEOMETRY", Message = $"No paper position for {item.SnapshotId}." });
|
||||
}
|
||||
|
||||
var stableKey = $"{request.Viewport.SourceDrawingName ?? string.Empty}:{request.Viewport.Id}:{item.SnapshotId}";
|
||||
var existingAnnotation = existing.TryGetValue(stableKey, out var match) ? match : existing.TryGetValue(item.SnapshotId, out var directMatch) ? directMatch : null;
|
||||
var kind = existingAnnotation is null ? AnnotationOperationKind.Create : AnnotationOperationKind.Update;
|
||||
operations.Add(new AnnotationOperationPlan
|
||||
{
|
||||
SourceSnapshotId = item.SnapshotId,
|
||||
Kind = kind,
|
||||
AnnotationType = "PlantObject",
|
||||
Text = text,
|
||||
Position = position,
|
||||
Rotation = item.Geometry.Rotation,
|
||||
ExistingAnnotationId = existingAnnotation?.StableKey,
|
||||
Reasons = Array.Empty<string>()
|
||||
});
|
||||
|
||||
var intent = new AnnotationIntent
|
||||
{
|
||||
StableKey = stableKey,
|
||||
Text = text,
|
||||
Position = position is { } point ? new AnnotationPoint(point.X, point.Y) : new AnnotationPoint(0, 0),
|
||||
Rotation = item.Geometry.Rotation ?? 0,
|
||||
ClassName = item.ObjectClass,
|
||||
Layer = "PXP_ANNOTATION"
|
||||
};
|
||||
if (kind == AnnotationOperationKind.Create) creates.Add(intent); else updates.Add(intent);
|
||||
}
|
||||
|
||||
var summary = new AnnotationPlanSummary
|
||||
{
|
||||
ObjectsExamined = objects.Length,
|
||||
CreateCount = operations.Count(x => x.Kind == AnnotationOperationKind.Create),
|
||||
UpdateCount = operations.Count(x => x.Kind == AnnotationOperationKind.Update),
|
||||
SkipCount = operations.Count(x => x.Kind == AnnotationOperationKind.Skip),
|
||||
ConflictCount = operations.Count(x => x.Kind == AnnotationOperationKind.Conflict),
|
||||
UnsupportedCount = operations.Count(x => x.Kind == AnnotationOperationKind.Unsupported),
|
||||
WarningCount = diagnostics.Count(x => !string.Equals(x.Severity, "Error", StringComparison.OrdinalIgnoreCase)),
|
||||
ErrorCount = diagnostics.Count(x => string.Equals(x.Severity, "Error", StringComparison.OrdinalIgnoreCase))
|
||||
};
|
||||
|
||||
return new AnnotationPlan
|
||||
{
|
||||
PlanId = BuildPlanId(request, objects),
|
||||
CommandName = request.CommandName,
|
||||
Operations = operations,
|
||||
Diagnostics = diagnostics,
|
||||
Summary = summary,
|
||||
Create = creates,
|
||||
Update = updates,
|
||||
Delete = Array.Empty<string>(),
|
||||
SkipReasons = operations.Where(x => x.Kind == AnnotationOperationKind.Skip).SelectMany(x => x.Reasons).ToArray(),
|
||||
ExaminedCount = objects.Length,
|
||||
IsDeterministic = true
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList<PlantObjectSnapshot> objects)
|
||||
{
|
||||
var canonical = new StringBuilder(request.CommandName).Append('|').Append(request.Viewport.SourceDrawingName).Append('|').Append(request.Viewport.Id);
|
||||
foreach (var item in objects)
|
||||
canonical.Append('|').Append(item.SnapshotId).Append('|').Append(item.ObjectClass).Append('|').Append(item.Tag).Append('|').Append(item.LineNumber).Append('|').Append(item.Geometry.PaperPosition);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()));
|
||||
return "plan-" + Convert.ToHexString(hash)[..24];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using PRecAnnot.Abstractions;
|
||||
|
||||
namespace PRecAnnot.Core;
|
||||
|
||||
public sealed class AnnotationRuleEngine : IAnnotationRuleEngine
|
||||
{
|
||||
public AnnotationPlan BuildPlan(AnnotationRequest request)
|
||||
{
|
||||
var objects = request.Objects.Select(item => new PlantObjectSnapshot
|
||||
{
|
||||
SnapshotId = item.ObjectId,
|
||||
ObjectClass = item.ClassName,
|
||||
Tag = item.Tag,
|
||||
LineNumber = item.LineNumber,
|
||||
Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(item.Position.X, item.Position.Y), Rotation = item.Rotation }
|
||||
}).ToArray();
|
||||
|
||||
return new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
|
||||
{
|
||||
CommandName = "PHP_REC_ANNOT",
|
||||
Viewport = new OrthoViewportInfo { Id = request.Viewport.ViewportId, DisplayName = request.Viewport.ViewportId, SourceDrawingName = request.Viewport.DrawingId },
|
||||
Objects = objects,
|
||||
Existing = request.Existing
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using PRecAnnot.Abstractions;
|
||||
|
||||
namespace PRecAnnot.Core;
|
||||
|
||||
public sealed class ExecutionModeWriteGuard(PRecAnnotExecutionMode mode) : IRuntimeWriteGuard
|
||||
{
|
||||
public void EnsureWriteAllowed(string operation) => throw new InvalidOperationException(mode == PRecAnnotExecutionMode.Apply ? "APPLY_MODE_NOT_IMPLEMENTED" : "SMOKE_WRITE_BLOCKED");
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using PRecAnnot.Abstractions;
|
||||
|
||||
namespace PRecAnnot.Core;
|
||||
|
||||
public static class OrthoDetectionClassifier
|
||||
{
|
||||
public static OrthoDetectionResult Classify(bool hasPlantProject, bool hasOrthoPart, bool apiConfirmed, bool metadataAmbiguous = false)
|
||||
{
|
||||
if (!hasPlantProject) return Result(OrthoDetectionStatus.NotOrtho, "NO_PLANT_PROJECT", "Active Plant project is absent.");
|
||||
if (metadataAmbiguous) return Result(OrthoDetectionStatus.Ambiguous, "AMBIGUOUS_ORTHO_METADATA", "Plant metadata is insufficient to confirm Ortho.");
|
||||
if (!hasOrthoPart) return Result(OrthoDetectionStatus.Unsupported, "ORTHO_PART_UNAVAILABLE", "Ortho project part is unavailable.");
|
||||
return apiConfirmed
|
||||
? Result(OrthoDetectionStatus.Confirmed, "ORTHO_API_CONFIRMED", "OrthoDocUtils confirmed the active drawing.")
|
||||
: Result(OrthoDetectionStatus.NotOrtho, "ORTHO_API_NEGATIVE", "OrthoDocUtils did not confirm the active drawing.");
|
||||
}
|
||||
|
||||
private static OrthoDetectionResult Result(OrthoDetectionStatus status, string code, string message) => new()
|
||||
{
|
||||
Status = status,
|
||||
Evidence = new[] { code },
|
||||
Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } }
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -1,195 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Autodesk.AutoCAD.ApplicationServices;
|
||||
using Autodesk.AutoCAD.Runtime;
|
||||
using Autodesk.Windows;
|
||||
using PRecAnnot.Abstractions;
|
||||
using PRecAnnot.Core;
|
||||
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
|
||||
|
||||
namespace PRecAnnot.Plant3D2026;
|
||||
|
||||
public sealed class EntryPoint : IExtensionApplication
|
||||
{
|
||||
public void Initialize()
|
||||
{
|
||||
RuntimeDiagnostics.Write("AssemblyInitializeStarted");
|
||||
RibbonIntegration.Register();
|
||||
RuntimeDiagnostics.Write("AssemblyInitializeCompleted");
|
||||
}
|
||||
|
||||
public void Terminate()
|
||||
{
|
||||
RuntimeDiagnostics.Write("AssemblyTerminateStarted");
|
||||
RibbonIntegration.Terminate();
|
||||
RuntimeDiagnostics.Write("AssemblyTerminateCompleted");
|
||||
}
|
||||
|
||||
[CommandMethod("PHP_REC_ANNOT")]
|
||||
public void PrimaryCommand() => RunDiagnostic("PHP_REC_ANNOT");
|
||||
|
||||
[CommandMethod("PXP-AAV")]
|
||||
public void AutoAnnotateViewCommand() => RunDiagnostic("PXP-AAV");
|
||||
|
||||
[CommandMethod("PXP-OA")]
|
||||
public void OrthoAnnotateCommand() => RunDiagnostic("PXP-OA");
|
||||
|
||||
private static void RunDiagnostic(string commandName)
|
||||
{
|
||||
var operationId = Guid.NewGuid().ToString("N");
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
Document? document = null;
|
||||
RuntimeDiagnostics.Write("CommandStarted", operationId: operationId, commandName: commandName);
|
||||
try
|
||||
{
|
||||
document = AcadApplication.DocumentManager.MdiActiveDocument;
|
||||
var mode = ParseMode(RuntimeDiagnostics.RuntimeMode);
|
||||
if (mode == PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_NOT_IMPLEMENTED");
|
||||
|
||||
var context = new PlantRuntimeContextReader().ReadCurrentContext();
|
||||
var details = new Dictionary<string, object?>
|
||||
{
|
||||
["ExecutionMode"] = mode.ToString(),
|
||||
["IsPlantProjectDetected"] = context.HasPlantProject,
|
||||
["IsPlantProjectOpen"] = context.HasPlantProject,
|
||||
["IsOrthoDrawing"] = context.IsOrthoDrawing,
|
||||
["ViewportCount"] = context.Viewports.Count,
|
||||
["DataLinksAvailable"] = DataLinksAvailable(context.HasPlantProject)
|
||||
};
|
||||
|
||||
if (document is null || !context.HasPlantProject)
|
||||
{
|
||||
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "PreflightRejected", document: document, details: details);
|
||||
WriteEditor(document, "\nP_Rec_Annot: Активный Plant 3D-проект не найден; DWG не изменён.");
|
||||
WriteUsage(operationId, commandName, "PreflightRejected", document, details, stopwatch.ElapsedMilliseconds);
|
||||
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "PreflightRejected", document: document, details: details);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == PRecAnnotExecutionMode.Smoke || !context.IsOrthoDrawing)
|
||||
{
|
||||
var status = context.IsOrthoDrawing ? "PreflightAccepted" : "NotOrtho";
|
||||
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: status, document: document, details: details);
|
||||
WriteEditor(document, $"\nP_Rec_Annot {commandName}: проект '{context.ProjectDisplayName}' найден; тип чертежа: {(context.IsOrthoDrawing ? "Ortho" : "NotOrtho")}; DWG не изменён.");
|
||||
WriteUsage(operationId, commandName, status, document, details, stopwatch.ElapsedMilliseconds);
|
||||
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: status, document: document, details: details);
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new AnnotationPlanBuilder();
|
||||
var plans = new List<AnnotationPlan>();
|
||||
foreach (var viewport in context.Viewports)
|
||||
{
|
||||
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None);
|
||||
plans.Add(builder.Build(new AnnotationPlanningRequest { CommandName = commandName, Viewport = viewport, Objects = objects }));
|
||||
}
|
||||
|
||||
var summary = new
|
||||
{
|
||||
ObjectsExamined = plans.Sum(x => x.Summary.ObjectsExamined),
|
||||
CreateCount = plans.Sum(x => x.Summary.CreateCount),
|
||||
UpdateCount = plans.Sum(x => x.Summary.UpdateCount),
|
||||
SkipCount = plans.Sum(x => x.Summary.SkipCount),
|
||||
ConflictCount = plans.Sum(x => x.Summary.ConflictCount),
|
||||
UnsupportedCount = plans.Sum(x => x.Summary.UnsupportedCount),
|
||||
WarningCount = plans.Sum(x => x.Summary.WarningCount),
|
||||
ErrorCount = plans.Sum(x => x.Summary.ErrorCount)
|
||||
};
|
||||
details["ObjectsExamined"] = summary.ObjectsExamined;
|
||||
details["PlanCreateCount"] = summary.CreateCount;
|
||||
details["PlanUpdateCount"] = summary.UpdateCount;
|
||||
details["PlanSkipCount"] = summary.SkipCount;
|
||||
details["PlanConflictCount"] = summary.ConflictCount;
|
||||
details["PlanUnsupportedCount"] = summary.UnsupportedCount;
|
||||
details["ErrorCount"] = summary.ErrorCount;
|
||||
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
|
||||
WriteEditor(document, $"\nP_Rec_Annot — диагностический план\nПроект: {context.ProjectDisplayName}\nТип чертежа: Ortho\nViewport: {context.Viewports.Count}\nОбъектов найдено: {summary.ObjectsExamined}\nБудет создано: {summary.CreateCount}\nБудет обновлено: {summary.UpdateCount}\nБудет пропущено: {summary.SkipCount}\nКонфликтов: {summary.ConflictCount}\nОшибок: {summary.ErrorCount}\nDWG не изменён.");
|
||||
WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds);
|
||||
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
|
||||
}
|
||||
catch (System.Exception exception)
|
||||
{
|
||||
RuntimeDiagnostics.Write("CommandFailed", "ERROR", operationId, commandName, exception.Message, exception, document);
|
||||
WriteUsage(operationId, commandName, exception.Message, document, null, stopwatch.ElapsedMilliseconds, exception);
|
||||
WriteEditor(document, $"\nP_Rec_Annot: безопасная диагностика завершилась ошибкой: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static PRecAnnotExecutionMode ParseMode(string value) =>
|
||||
value.Equals("Smoke", StringComparison.OrdinalIgnoreCase) ? PRecAnnotExecutionMode.Smoke :
|
||||
value.Equals("Apply", StringComparison.OrdinalIgnoreCase) ? PRecAnnotExecutionMode.Apply : PRecAnnotExecutionMode.DryRun;
|
||||
|
||||
private static bool DataLinksAvailable(bool hasPlantProject)
|
||||
{
|
||||
try { return hasPlantProject && Autodesk.ProcessPower.PlantInstance.PlantApplication.ProjectPart("Piping")?.DataLinksManager is not null; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static void WriteUsage(string operationId, string commandName, string status, Document? document, IReadOnlyDictionary<string, object?>? details, long duration, System.Exception? exception = null)
|
||||
{
|
||||
var eventDetails = new Dictionary<string, object?>
|
||||
{
|
||||
["ObjectsExamined"] = 0, ["AnnotationsCreated"] = 0, ["AnnotationsUpdated"] = 0,
|
||||
["AnnotationsDeleted"] = 0, ["AnnotationsSkipped"] = 0, ["WarningCount"] = 0,
|
||||
["ErrorCount"] = exception is null ? 0 : 1, ["DurationMilliseconds"] = duration
|
||||
};
|
||||
if (details is not null) foreach (var item in details) eventDetails[item.Key] = item.Value;
|
||||
RuntimeDiagnostics.Write("CommandUsageEvent", exception is null ? "INFO" : "ERROR", operationId, commandName, status, exception, document, eventDetails);
|
||||
}
|
||||
|
||||
private static void WriteEditor(Document? document, string message)
|
||||
{
|
||||
try { document?.Editor.WriteMessage(message); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RibbonIntegration
|
||||
{
|
||||
private const string TabId = "PRecAnnot.Plant3D2026";
|
||||
private const string PanelId = "PRecAnnot.Plant3D2026.Annotations";
|
||||
private const string ButtonId = "PRecAnnot.Plant3D2026.PHP_REC_ANNOT";
|
||||
private static bool deferred;
|
||||
|
||||
public static void Register()
|
||||
{
|
||||
RuntimeDiagnostics.Write("RibbonInitializationStarted");
|
||||
try
|
||||
{
|
||||
if (ComponentManager.Ribbon is null)
|
||||
{
|
||||
if (!deferred) { ComponentManager.ItemInitialized += OnItemInitialized; deferred = true; }
|
||||
RuntimeDiagnostics.Write("RibbonInitializationCompleted", resultStatus: "Deferred");
|
||||
return;
|
||||
}
|
||||
RegisterNow();
|
||||
}
|
||||
catch (System.Exception exception) { RuntimeDiagnostics.Write("RibbonInitializationCompleted", "ERROR", exception: exception, resultStatus: "Failed"); }
|
||||
}
|
||||
|
||||
private static void OnItemInitialized(object? sender, EventArgs args)
|
||||
{
|
||||
if (ComponentManager.Ribbon is null) return;
|
||||
ComponentManager.ItemInitialized -= OnItemInitialized;
|
||||
deferred = false;
|
||||
Register();
|
||||
}
|
||||
|
||||
private static void RegisterNow()
|
||||
{
|
||||
var ribbon = ComponentManager.Ribbon!;
|
||||
var tab = ribbon.Tabs.FirstOrDefault(x => string.Equals(x.Id, TabId, StringComparison.OrdinalIgnoreCase));
|
||||
if (tab is null) { tab = new RibbonTab { Id = TabId, Title = "Промхимпроект" }; ribbon.Tabs.Add(tab); }
|
||||
var panel = tab.Panels.FirstOrDefault(x => string.Equals(x.Source?.Id, PanelId, StringComparison.OrdinalIgnoreCase));
|
||||
if (panel is not null) { RuntimeDiagnostics.Write("RibbonAlreadyExists", resultStatus: "AlreadyExists"); RuntimeDiagnostics.Write("RibbonInitializationCompleted", resultStatus: "AlreadyExists"); return; }
|
||||
var source = new RibbonPanelSource { Id = PanelId, Title = "Аннотации" };
|
||||
source.Items.Add(new RibbonButton { Id = ButtonId, Text = "Аннотировать", ShowText = true, CommandParameter = "PHP_REC_ANNOT " });
|
||||
tab.Panels.Add(new RibbonPanel { Source = source });
|
||||
RuntimeDiagnostics.Write("RibbonInitializationCompleted", resultStatus: "Created");
|
||||
}
|
||||
|
||||
public static void Terminate()
|
||||
{
|
||||
if (!deferred) return;
|
||||
ComponentManager.ItemInitialized -= OnItemInitialized;
|
||||
deferred = false;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><PlatformTarget>x64</PlatformTarget><UseWindowsForms>true</UseWindowsForms><EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\PRecAnnot.Application\PRecAnnot.Application.csproj" />
|
||||
<ProjectReference Include="..\PRecAnnot.Core\PRecAnnot.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="Exists('$(AutoCAD2026ManagedDir)') and Exists('$(Plant3D2026ManagedDir)')">
|
||||
<Reference Include="AcMgd"><HintPath>$(AutoCAD2026ManagedDir)\AcMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="AcDbMgd"><HintPath>$(AutoCAD2026ManagedDir)\AcDbMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="AcCoreMgd"><HintPath>$(AutoCAD2026ManagedDir)\AcCoreMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="AdWindows"><HintPath>$(AutoCAD2026ManagedDir)\AdWindows.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="PnPDataLinks"><HintPath>$(AutoCAD2026ManagedDir)\PnPDataLinks.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="PnPDataObjects"><HintPath>$(AutoCAD2026ManagedDir)\PnPDataObjects.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="PnPProjectManagerMgd"><HintPath>$(Plant3D2026ManagedDir)\PnPProjectManagerMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="PnP3dObjectsMgd"><HintPath>$(Plant3D2026ManagedDir)\PnP3dObjectsMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="PnP3dOrthoProjectPart"><HintPath>$(Plant3D2026ManagedDir)\PnP3dOrthoProjectPart.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
<Reference Include="PnPDwg2dDef"><HintPath>$(Plant3D2026ManagedDir)\PnPDwg2dDef.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace PRecAnnot.Plant3D2026;
|
||||
|
||||
public sealed record Plant3D2026Availability(bool IsAvailable, string InstallDirectory, IReadOnlyList<string> MissingAssemblies)
|
||||
{
|
||||
public static Plant3D2026Availability Detect(string installDirectory)
|
||||
{
|
||||
var expected = new[] { "AcMgd.dll", "AcDbMgd.dll", "AcCoreMgd.dll", "PnPDataLinks.dll", Path.Combine("PLNT3D", "PnPProjectManagerMgd.dll"), Path.Combine("PLNT3D", "PnP3dObjectsMgd.dll") };
|
||||
var missing = expected.Where(x => !File.Exists(Path.Combine(installDirectory, x))).ToArray();
|
||||
return new(missing.Length == 0, installDirectory, missing);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Plant3D2026BlockedException(Plant3D2026Availability availability)
|
||||
: InvalidOperationException($"Plant 3D 2026 assemblies are unavailable at '{availability.InstallDirectory}'. Missing: {string.Join(", ", availability.MissingAssemblies)}") { }
|
||||
@@ -1,206 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Autodesk.AutoCAD.ApplicationServices;
|
||||
using Autodesk.ProcessPower.DataLinks;
|
||||
using Autodesk.ProcessPower.P3dProjectParts;
|
||||
using Autodesk.ProcessPower.PlantInstance;
|
||||
using Autodesk.ProcessPower.ProjectManager;
|
||||
using PRecAnnot.Abstractions;
|
||||
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
|
||||
|
||||
namespace PRecAnnot.Plant3D2026;
|
||||
|
||||
internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
|
||||
{
|
||||
public PlantRuntimeContext ReadCurrentContext()
|
||||
{
|
||||
var document = AcadApplication.DocumentManager.MdiActiveDocument;
|
||||
if (document is null)
|
||||
{
|
||||
return Empty(false, false, "NO_ACTIVE_DOCUMENT", "Активный документ отсутствует.");
|
||||
}
|
||||
|
||||
PlantProject? project;
|
||||
try { project = PlantApplication.CurrentProject; }
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Empty(true, false, "PLANT_PROJECT_READ_FAILED", exception.GetType().Name);
|
||||
}
|
||||
|
||||
if (project is null)
|
||||
{
|
||||
return Empty(true, false, "NO_PLANT_PROJECT", "Активный Plant 3D-проект не найден.");
|
||||
}
|
||||
|
||||
OrthoProject? orthoProject;
|
||||
try { orthoProject = PlantApplication.ProjectPart("Ortho") as OrthoProject; }
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Empty(true, true, "ORTHO_PROJECT_PART_READ_FAILED", exception.GetType().Name);
|
||||
}
|
||||
|
||||
if (orthoProject is null)
|
||||
{
|
||||
return Empty(true, true, "ORTHO_PROJECT_PART_UNAVAILABLE", "Ortho project part недоступен.", OrthoDetectionStatus.Unsupported, "ProjectPart(Ortho)");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string resolvedPath;
|
||||
var isOrtho = OrthoDocUtils.IsValidOrthoDrawing(document.Name, orthoProject, out resolvedPath);
|
||||
if (!isOrtho)
|
||||
{
|
||||
return Empty(true, true, "NOT_ORTHO_DRAWING", "OrthoDocUtils.IsValidOrthoDrawing вернул false.", OrthoDetectionStatus.NotOrtho, "Ortho project part", "OrthoDocUtils.IsValidOrthoDrawing=false");
|
||||
}
|
||||
|
||||
var viewports = new PlantOrthoViewportReader(orthoProject, document.Name).ReadViewports(CancellationToken.None);
|
||||
return new PlantRuntimeContext
|
||||
{
|
||||
HasActiveDocument = true,
|
||||
HasPlantProject = true,
|
||||
IsOrthoDrawing = true,
|
||||
DrawingName = Path.GetFileName(document.Name),
|
||||
ProjectDisplayName = project.Name,
|
||||
Viewports = viewports,
|
||||
Diagnostics = Array.Empty<RuntimeDiagnostic>(),
|
||||
Detection = new OrthoDetectionResult
|
||||
{
|
||||
Status = OrthoDetectionStatus.Confirmed,
|
||||
Evidence = new[] { "ProjectPart(Ortho)", "OrthoDocUtils.IsValidOrthoDrawing=true", "DwgSheetDefinitions" },
|
||||
Diagnostics = Array.Empty<RuntimeDiagnostic>()
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Empty(true, true, "ORTHO_DETECTION_FAILED", exception.GetType().Name, OrthoDetectionStatus.Failed, "ProjectPart(Ortho)");
|
||||
}
|
||||
}
|
||||
|
||||
private static PlantRuntimeContext Empty(bool hasDocument, bool hasProject, string code, string message, OrthoDetectionStatus status = OrthoDetectionStatus.NotOrtho, params string[] evidence) => new()
|
||||
{
|
||||
HasActiveDocument = hasDocument,
|
||||
HasPlantProject = hasProject,
|
||||
IsOrthoDrawing = false,
|
||||
Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } },
|
||||
Detection = new OrthoDetectionResult { Status = status, Evidence = evidence, Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } } }
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class PlantOrthoViewportReader : IOrthoViewportReader
|
||||
{
|
||||
private readonly OrthoProject project;
|
||||
private readonly string drawingName;
|
||||
|
||||
public PlantOrthoViewportReader(OrthoProject project, string drawingName)
|
||||
{
|
||||
this.project = project;
|
||||
this.drawingName = Path.GetFileName(drawingName);
|
||||
}
|
||||
|
||||
public IReadOnlyList<OrthoViewportInfo> ReadViewports(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<OrthoViewportInfo>();
|
||||
foreach (var sheet in project.DwgSheetDefinitions.AsArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!string.Equals(Path.GetFileName(sheet.DwgName), drawingName, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
foreach (var view in sheet.DwgViewDefs.AsArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var id = StableId(sheet.DwgGuid.ToString(), view.ViewName);
|
||||
result.Add(new OrthoViewportInfo
|
||||
{
|
||||
Id = id,
|
||||
DisplayName = view.ViewName,
|
||||
CenterX = view.CenterPoint.X,
|
||||
CenterY = view.CenterPoint.Y,
|
||||
Width = view.ViewWidth,
|
||||
Height = view.ViewHeight,
|
||||
Scale = view.ViewScale,
|
||||
TwistAngle = 0,
|
||||
IsOn = true,
|
||||
IsLocked = false,
|
||||
SourceDrawingName = sheet.DwgName,
|
||||
ViewName = view.ViewName
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// One malformed view must not abort the remaining read-only analysis.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.OrderByDescending(x => x.CenterY).ThenBy(x => x.CenterX).ThenBy(x => x.Id, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static string StableId(string sheetId, string viewName)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sheetId + "|" + viewName));
|
||||
return "vp-" + Convert.ToHexString(hash)[..20];
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlantObjectReader : IPlantObjectReader
|
||||
{
|
||||
public IReadOnlyList<PlantObjectSnapshot> ReadObjects(OrthoViewportInfo viewport, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = AcadApplication.DocumentManager.MdiActiveDocument;
|
||||
if (document is null) return Array.Empty<PlantObjectSnapshot>();
|
||||
|
||||
DataLinksManager? dataLinks;
|
||||
try { dataLinks = PlantApplication.ProjectPart("Piping")?.DataLinksManager; }
|
||||
catch { return Array.Empty<PlantObjectSnapshot>(); }
|
||||
if (dataLinks is null) return Array.Empty<PlantObjectSnapshot>();
|
||||
|
||||
var snapshots = new List<PlantObjectSnapshot>();
|
||||
foreach (var rowId in dataLinks.SelectAcPpRowIds(document.Database))
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var className = dataLinks.GetObjectClassname(rowId);
|
||||
var properties = dataLinks.GetAllProperties(rowId, true)
|
||||
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.OrdinalIgnoreCase);
|
||||
string? Value(params string[] names) => properties.FirstOrDefault(x => names.Any(name => string.Equals(name, x.Key, StringComparison.OrdinalIgnoreCase))).Value;
|
||||
snapshots.Add(new PlantObjectSnapshot
|
||||
{
|
||||
SnapshotId = StableId(Path.GetFileName(document.Name), rowId, className),
|
||||
ObjectClass = className ?? string.Empty,
|
||||
Tag = Value("Tag", "TAG"),
|
||||
LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"),
|
||||
SupportNumber = Value("Support Number", "SupportNumber"),
|
||||
EquipmentNumber = Value("Equipment Number", "EquipmentNumber"),
|
||||
Geometry = new GeometrySnapshot(),
|
||||
Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase),
|
||||
Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" }
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep the read path best-effort; no write operation is attempted.
|
||||
}
|
||||
}
|
||||
return snapshots.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static string StableId(string drawingName, int rowId, string? className)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingName}|{rowId}|{className}"));
|
||||
return "obj-" + Convert.ToHexString(hash)[..24];
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RuntimeWriteGuard(PRecAnnotExecutionMode mode) : IRuntimeWriteGuard
|
||||
{
|
||||
public void EnsureWriteAllowed(string operation)
|
||||
{
|
||||
if (mode == PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_NOT_IMPLEMENTED");
|
||||
throw new InvalidOperationException("SMOKE_WRITE_BLOCKED");
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Autodesk.AutoCAD.ApplicationServices;
|
||||
using Autodesk.ProcessPower.PlantInstance;
|
||||
|
||||
namespace PRecAnnot.Plant3D2026;
|
||||
|
||||
internal static class RuntimeDiagnostics
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static readonly string LogDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Promhimproekt", "PRecAnnot2026", "Logs");
|
||||
|
||||
public static string RuntimeMode => Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE") ?? "Normal";
|
||||
|
||||
public static void Write(
|
||||
string eventType,
|
||||
string level = "INFO",
|
||||
string? operationId = null,
|
||||
string? commandName = null,
|
||||
string? resultStatus = null,
|
||||
System.Exception? exception = null,
|
||||
Document? document = null,
|
||||
IReadOnlyDictionary<string, object?>? details = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["Timestamp"] = DateTimeOffset.UtcNow,
|
||||
["Level"] = level,
|
||||
["OperationId"] = operationId,
|
||||
["EventType"] = eventType,
|
||||
["AssemblyVersion"] = typeof(EntryPoint).Assembly.GetName().Version?.ToString(),
|
||||
["AutoCADVersion"] = typeof(Document).Assembly.GetName().Version?.ToString(),
|
||||
["PlantVersion"] = typeof(PlantApplication).Assembly.GetName().Version?.ToString(),
|
||||
["ProcessId"] = Environment.ProcessId,
|
||||
["ThreadId"] = Environment.CurrentManagedThreadId,
|
||||
["ApartmentState"] = Thread.CurrentThread.GetApartmentState().ToString(),
|
||||
["ActiveDocumentName"] = SafeDocumentName(document),
|
||||
["CommandName"] = commandName,
|
||||
["ResultStatus"] = resultStatus,
|
||||
["ExceptionType"] = exception?.GetType().FullName,
|
||||
["ExceptionMessage"] = exception?.Message,
|
||||
["StackTrace"] = level.Equals("ERROR", StringComparison.OrdinalIgnoreCase) ? exception?.StackTrace : null,
|
||||
["RuntimeMode"] = RuntimeMode
|
||||
};
|
||||
|
||||
if (details is not null)
|
||||
foreach (var item in details) payload[item.Key] = item.Value;
|
||||
|
||||
var line = JsonSerializer.Serialize(payload) + Environment.NewLine;
|
||||
lock (Gate)
|
||||
{
|
||||
Directory.CreateDirectory(LogDirectory);
|
||||
File.AppendAllText(Path.Combine(LogDirectory, "runtime.jsonl"), line);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Runtime diagnostics must never prevent AutoCAD command execution.
|
||||
}
|
||||
}
|
||||
|
||||
private static string? SafeDocumentName(Document? document)
|
||||
{
|
||||
try { return document is null ? null : Path.GetFileName(document.Name); }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SmokeWriteBlockedException() : InvalidOperationException("SMOKE_WRITE_BLOCKED");
|
||||
|
||||
internal static class SmokeGuard
|
||||
{
|
||||
public static bool IsSmoke => string.Equals(RuntimeDiagnostics.RuntimeMode, "Smoke", StringComparison.OrdinalIgnoreCase);
|
||||
public static void EnsureReadOnly() { if (IsSmoke) throw new SmokeWriteBlockedException(); }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using PRecAnnot.Abstractions;
|
||||
|
||||
namespace PRecAnnot.Telemetry;
|
||||
|
||||
public sealed class JsonlUsageSink(string path) : ICommandUsageSink
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
public void Record(CommandUsageEvent usageEvent)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(usageEvent);
|
||||
lock (_gate) File.AppendAllText(path, json + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -1,4 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><UseWindowsForms>true</UseWindowsForms></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="..\PRecAnnot.Application\PRecAnnot.Application.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -1,17 +0,0 @@
|
||||
using PRecAnnot.Application;
|
||||
|
||||
namespace PRecAnnot.UI;
|
||||
|
||||
public sealed class RibbonRegistration
|
||||
{
|
||||
private readonly HashSet<string> _registered = new(StringComparer.OrdinalIgnoreCase);
|
||||
public bool Register(CommandRegistry registry)
|
||||
{
|
||||
var changed = false;
|
||||
foreach (var item in registry.Commands)
|
||||
changed |= _registered.Add($"{item.RibbonPanel}:{item.CommandName}");
|
||||
return changed;
|
||||
}
|
||||
public void Unregister(CommandRegistry registry) { foreach (var item in registry.Commands) _registered.Remove($"{item.RibbonPanel}:{item.CommandName}"); }
|
||||
public int RegisteredCount => _registered.Count;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using PRecAnnot.Abstractions;
|
||||
using PRecAnnot.Application;
|
||||
using PRecAnnot.Core;
|
||||
using PRecAnnot.UI;
|
||||
|
||||
namespace PRecAnnot.Core.Tests;
|
||||
|
||||
public sealed class FoundationTests
|
||||
{
|
||||
private static string ProjectRoot
|
||||
{
|
||||
get
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "PRecAnnot2026.sln"))) directory = directory.Parent;
|
||||
return directory?.FullName ?? throw new DirectoryNotFoundException("PRecAnnot2026.sln was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact] public void Plan_IsDeterministic()
|
||||
{
|
||||
var engine = new AnnotationRuleEngine();
|
||||
var request = new AnnotationRequest { Viewport = new("d", "v"), Objects = new[] { new PlantObjectData { ObjectId = "2", ClassName = "Pipe", LineNumber = "L-2", Position = new(2, 2) }, new PlantObjectData { ObjectId = "1", ClassName = "Pipe", LineNumber = "L-1", Position = new(1, 1) } }, Existing = Array.Empty<ExistingAnnotation>() };
|
||||
var plan = engine.BuildPlan(request);
|
||||
Assert.Equal(new[] { "1", "2" }, plan.Create.Select(x => x.StableKey.Split(':').Last()));
|
||||
}
|
||||
|
||||
[Fact] public void ExistingAnnotation_IsUpdatedNotDuplicated()
|
||||
{
|
||||
var item = new PlantObjectData { ObjectId = "1", ClassName = "Valve", Tag = "V-1", Position = new(0, 0) };
|
||||
var plan = new AnnotationRuleEngine().BuildPlan(new AnnotationRequest { Viewport = new("d", "v"), Objects = new[] { item }, Existing = new[] { new ExistingAnnotation("d:v:1", "old", new(0, 0)) } });
|
||||
Assert.Empty(plan.Create); Assert.Single(plan.Update);
|
||||
}
|
||||
|
||||
[Fact] public void CoreContracts_DoNotExposeAutodeskTypes()
|
||||
{
|
||||
var names = typeof(AnnotationRequest).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray();
|
||||
Assert.DoesNotContain(names, x => x is not null && x.StartsWith("Ac", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(names, x => x is not null && x.StartsWith("Autodesk", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact] public void RibbonRegistration_IsIdempotent()
|
||||
{
|
||||
var ribbon = new RibbonRegistration(); var registry = CommandRegistry.Foundation();
|
||||
Assert.True(ribbon.Register(registry)); Assert.False(ribbon.Register(registry)); Assert.Equal(3, ribbon.RegisteredCount);
|
||||
}
|
||||
|
||||
[Fact] public void PlantAdapter_LoadsAgainst2026Assemblies()
|
||||
{
|
||||
var adapter = Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "bin", "Release", "net8.0-windows", "PRecAnnot.Plant3D2026.dll");
|
||||
var assembly = System.Reflection.AssemblyName.GetAssemblyName(adapter);
|
||||
var project = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PRecAnnot.Plant3D2026.csproj"));
|
||||
Assert.NotNull(assembly);
|
||||
Assert.Contains("AcMgd", project);
|
||||
Assert.Contains("PnPProjectManagerMgd", project);
|
||||
}
|
||||
|
||||
[Fact] public void CommandClass_HasCommandMethodAttributes()
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs"));
|
||||
Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", source);
|
||||
Assert.Contains("[CommandMethod(\"PXP-AAV\")]", source);
|
||||
Assert.Contains("[CommandMethod(\"PXP-OA\")]", source);
|
||||
}
|
||||
|
||||
[Fact] public void PrimaryCommand_IsRegistered() => Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));
|
||||
[Fact] public void LegacyAliases_AreRegistered() => Assert.True(File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")).Contains("PXP-AAV") && File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")).Contains("PXP-OA"));
|
||||
[Fact] public void RibbonCommand_ReferencesRegisteredCommand() => Assert.Contains("PHP_REC_ANNOT ", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));
|
||||
|
||||
[Fact] public void AutodeskReferences_AreNotCopyLocal()
|
||||
{
|
||||
var project = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PRecAnnot.Plant3D2026.csproj"));
|
||||
Assert.DoesNotContain("<Private>true</Private>", project);
|
||||
Assert.DoesNotContain("<CopyLocal>true</CopyLocal>", project);
|
||||
}
|
||||
|
||||
[Fact] public void Bundle_DoesNotContainAutodeskAssemblies()
|
||||
{
|
||||
var root = Path.Combine(ProjectRoot, "artifacts", "P_Rec_Annot.bundle");
|
||||
if (!Directory.Exists(root)) return;
|
||||
Assert.DoesNotContain(Directory.EnumerateFiles(root, "*.dll", SearchOption.AllDirectories), path => Path.GetFileName(path).StartsWith("PnP", StringComparison.OrdinalIgnoreCase) || new[] { "AcMgd.dll", "AcDbMgd.dll", "AcCoreMgd.dll", "AdWindows.dll" }.Contains(Path.GetFileName(path), StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact] public void Core_HasNoAutodeskReferences()
|
||||
{
|
||||
var references = typeof(AnnotationRequest).Assembly.GetReferencedAssemblies();
|
||||
Assert.DoesNotContain(references, x => x.Name?.StartsWith("Autodesk", StringComparison.OrdinalIgnoreCase) == true || x.Name?.StartsWith("Ac", StringComparison.OrdinalIgnoreCase) == true);
|
||||
}
|
||||
|
||||
[Fact] public void OrthoDetection_NoPlantProject_ReturnsNotOrtho() => Assert.Equal(OrthoDetectionStatus.NotOrtho, OrthoDetectionClassifier.Classify(false, false, false).Status);
|
||||
[Fact] public void OrthoDetection_NormalDwg_ReturnsNotOrtho() => Assert.Equal(OrthoDetectionStatus.NotOrtho, OrthoDetectionClassifier.Classify(true, true, false).Status);
|
||||
[Fact] public void OrthoDetection_ConfirmedOrtho_ReturnsConfirmed() => Assert.Equal(OrthoDetectionStatus.Confirmed, OrthoDetectionClassifier.Classify(true, true, true).Status);
|
||||
[Fact] public void OrthoDetection_AmbiguousMetadata_ReturnsAmbiguous() => Assert.Equal(OrthoDetectionStatus.Ambiguous, OrthoDetectionClassifier.Classify(true, true, false, true).Status);
|
||||
|
||||
[Fact] public void AnnotationPlan_InputOrderDoesNotChangeResult()
|
||||
{
|
||||
var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "A", SourceDrawingName = "test.dwg" };
|
||||
var objects = new[] { Snapshot("b", "Pipe", "L-2"), Snapshot("a", "Valve", "V-1") };
|
||||
var builder = new AnnotationPlanBuilder();
|
||||
var first = builder.Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = viewport, Objects = objects });
|
||||
var second = builder.Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = viewport, Objects = objects.Reverse().ToArray() });
|
||||
Assert.Equal(first.PlanId, second.PlanId);
|
||||
Assert.Equal(first.Summary, second.Summary);
|
||||
Assert.Equal(first.Operations.Select(x => x.SourceSnapshotId), second.Operations.Select(x => x.SourceSnapshotId));
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_MissingLineNumber_IsSkipped()
|
||||
{
|
||||
var plan = Build(Snapshot("a", "Pipe", null, null));
|
||||
Assert.Equal(AnnotationOperationKind.Skip, Assert.Single(plan.Operations).Kind);
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_UnsupportedObject_IsMarkedUnsupported()
|
||||
{
|
||||
var plan = Build(Snapshot("a", "", "L-1"));
|
||||
Assert.Equal(AnnotationOperationKind.Unsupported, Assert.Single(plan.Operations).Kind);
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_ExistingAnnotation_BecomesUpdate()
|
||||
{
|
||||
var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "A", SourceDrawingName = "test.dwg" };
|
||||
var item = Snapshot("a", "Pipe", "L-1");
|
||||
var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = viewport, Objects = new[] { item }, Existing = new[] { new ExistingAnnotation("a", "old", new(0, 0)) } });
|
||||
Assert.Equal(AnnotationOperationKind.Update, Assert.Single(plan.Operations).Kind);
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_DuplicateCandidate_BecomesConflict()
|
||||
{
|
||||
var plan = Build(Snapshot("a", "Pipe", "L-1"), Snapshot("a", "Pipe", "L-1"));
|
||||
Assert.Equal(AnnotationOperationKind.Conflict, plan.Operations.Last().Kind);
|
||||
}
|
||||
|
||||
[Fact] public void ApplyMode_IsBlocked() => Assert.Throws<InvalidOperationException>(() => new ExecutionModeWriteGuard(PRecAnnotExecutionMode.Apply).EnsureWriteAllowed("test"));
|
||||
|
||||
[Fact] public void DryRunPath_HasNoForWriteOperations()
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
|
||||
Assert.DoesNotContain("ForWrite", source);
|
||||
Assert.DoesNotContain("AppendEntity", source);
|
||||
Assert.DoesNotContain("AcceptChanges", source);
|
||||
Assert.DoesNotContain("SetProperties", source);
|
||||
}
|
||||
|
||||
private static AnnotationPlan Build(params PlantObjectSnapshot[] objects) => new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "A", SourceDrawingName = "test.dwg" }, Objects = objects });
|
||||
private static PlantObjectSnapshot Snapshot(string id, string type, string? line, string? tag = "TAG") => new() { SnapshotId = id, ObjectClass = type, LineNumber = line, Tag = tag, Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(1, 2) } };
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="..\..\src\PRecAnnot.Core\PRecAnnot.Core.csproj" /><ProjectReference Include="..\..\src\PRecAnnot.Application\PRecAnnot.Application.csproj" /><ProjectReference Include="..\..\src\PRecAnnot.UI\PRecAnnot.UI.csproj" /></ItemGroup>
|
||||
<ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /><PackageReference Include="xunit" Version="2.5.3" /><PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /></ItemGroup>
|
||||
<ItemGroup><Using Include="Xunit" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -1,11 +0,0 @@
|
||||
param([ValidateSet('Debug','Release')][string]$Configuration='Release',[Parameter(Mandatory=$true)][string]$AutoCAD2026ManagedDir,[Parameter(Mandatory=$true)][string]$Plant3D2026ManagedDir)
|
||||
$ErrorActionPreference='Stop'
|
||||
dotnet build (Join-Path $PSScriptRoot '..\PRecAnnot2026.sln') --configuration $Configuration /p:AutoCAD2026ManagedDir=$AutoCAD2026ManagedDir /p:Plant3D2026ManagedDir=$Plant3D2026ManagedDir
|
||||
if($LASTEXITCODE -ne 0){ throw "Build failed: $LASTEXITCODE" }
|
||||
$bundleRoot=Join-Path $PSScriptRoot '..\artifacts\P_Rec_Annot.bundle'
|
||||
$contents=Join-Path $bundleRoot 'Contents\Windows'
|
||||
New-Item -ItemType Directory -Force -Path $contents | Out-Null
|
||||
$output=Join-Path $PSScriptRoot "..\src\PRecAnnot.Plant3D2026\bin\$Configuration\net8.0-windows"
|
||||
Get-ChildItem $output -File -Filter '*.dll' | Copy-Item -Destination $contents -Force
|
||||
Copy-Item (Join-Path $PSScriptRoot '..\bundle\P_Rec_Annot.bundle\PackageContents.xml') $bundleRoot -Force
|
||||
Write-Host 'Bundle assembly build completed; deployment is intentionally not performed.'
|
||||
@@ -1,13 +0,0 @@
|
||||
param([string]$BundleRoot=(Join-Path $PSScriptRoot '..\artifacts\P_Rec_Annot.bundle'))
|
||||
$ErrorActionPreference='Stop'
|
||||
$package=Join-Path $BundleRoot 'PackageContents.xml'
|
||||
[xml]$xml=Get-Content -LiteralPath $package
|
||||
$entry=$xml.ApplicationPackage.Components.ComponentEntry
|
||||
if(@($entry).Count -ne 1){throw 'Bundle must have exactly one entry assembly.'}
|
||||
if($entry.ModuleName -match '_netload|OrthoLoader|Settings.xml'){throw 'Legacy loading chain is forbidden.'}
|
||||
if($entry.ModuleName -notmatch 'PRecAnnot\.Plant3D2026\.dll'){throw 'Unexpected entry assembly.'}
|
||||
if(-not (Test-Path (Join-Path $BundleRoot 'Contents\Windows\PRecAnnot.Plant3D2026.dll'))){throw 'Entry assembly is missing.'}
|
||||
$forbidden='^(AcMgd|AcDbMgd|AcCoreMgd|AdWindows|PnP.*)\.dll$'
|
||||
$forbiddenFiles=Get-ChildItem $BundleRoot -Recurse -File | Where-Object { $_.Name -match $forbidden -or $_.Extension -in '.pdb','.cs' -or $_.FullName -match '\\(bin|obj)\\' }
|
||||
if($forbiddenFiles){throw "Bundle contains forbidden files: $($forbiddenFiles.FullName -join ', ')"}
|
||||
Write-Host 'Bundle manifest is structurally valid.'
|
||||
@@ -1,84 +0,0 @@
|
||||
param(
|
||||
[string]$DeployedRoot = '\\phpsrv03\NBIM\save\Apps\Plant3D\DriveA\P_Rec_Annot',
|
||||
[string]$SourceRoot = '\\phpsrv03\NBIM\code\CS\P_Rec_Annot\v1.0.4\repo',
|
||||
[string]$OutputRoot = (Split-Path -Parent $PSCommandPath)
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-PeArchitecture([string]$Path) {
|
||||
if ([IO.Path]::GetExtension($Path) -notin '.dll', '.exe') { return $null }
|
||||
try {
|
||||
$stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = [IO.BinaryReader]::new($stream)
|
||||
$stream.Position = 0x3c; $offset = $reader.ReadInt32(); $stream.Position = $offset + 4
|
||||
$machine = $reader.ReadUInt16()
|
||||
switch ($machine) { 0x14c { 'x86' } 0x8664 { 'x64' } 0x1c4 { 'ARM' } default { 'Unknown' } }
|
||||
} finally { $stream.Dispose() }
|
||||
} catch { 'Unreadable' }
|
||||
}
|
||||
|
||||
function Get-AssemblyMetadata([string]$Path) {
|
||||
$result = [ordered]@{ AssemblyName = $null; AssemblyVersion = $null; TargetFramework = $null }
|
||||
if ([IO.Path]::GetExtension($Path) -ne '.dll') { return [pscustomobject]$result }
|
||||
try {
|
||||
$name = [Reflection.AssemblyName]::GetAssemblyName($Path)
|
||||
$result.AssemblyName = $name.Name; $result.AssemblyVersion = $name.Version.ToString()
|
||||
try {
|
||||
$assembly = [Reflection.Assembly]::ReflectionOnlyLoadFrom($Path)
|
||||
$attribute = $assembly.GetCustomAttributesData() | Where-Object { $_.AttributeType.FullName -eq 'System.Runtime.Versioning.TargetFrameworkAttribute' } | Select-Object -First 1
|
||||
if ($attribute) { $result.TargetFramework = [string]$attribute.ConstructorArguments[0].Value }
|
||||
} catch { }
|
||||
} catch { }
|
||||
[pscustomobject]$result
|
||||
}
|
||||
|
||||
$inventory = foreach ($file in Get-ChildItem -LiteralPath $DeployedRoot -Recurse -File) {
|
||||
$hash = Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256
|
||||
$version = try { [Diagnostics.FileVersionInfo]::GetVersionInfo($file.FullName) } catch { $null }
|
||||
$meta = Get-AssemblyMetadata $file.FullName
|
||||
[pscustomobject]@{
|
||||
RelativePath = $file.FullName.Substring($DeployedRoot.Length).TrimStart('\')
|
||||
Name = $file.Name; Extension = $file.Extension; Length = $file.Length; LastWriteTimeUtc = $file.LastWriteTimeUtc.ToString('o')
|
||||
Sha256 = $hash.Hash; FileVersion = $version.FileVersion; ProductVersion = $version.ProductVersion
|
||||
AssemblyName = $meta.AssemblyName; AssemblyVersion = $meta.AssemblyVersion; TargetFramework = $meta.TargetFramework
|
||||
PeArchitecture = Get-PeArchitecture $file.FullName
|
||||
}
|
||||
}
|
||||
$inventory | Sort-Object RelativePath | Export-Csv -LiteralPath (Join-Path $OutputRoot 'deployed-files.csv') -NoTypeInformation -Encoding utf8
|
||||
|
||||
$settings = [xml](Get-Content -LiteralPath (Join-Path $DeployedRoot 'Settings.xml'))
|
||||
$sequence = 0
|
||||
$configuration = foreach ($item in $settings.Main.LoadAssemblies.LoadAssembly) {
|
||||
$sequence++
|
||||
$configured = [string]$item.assemblyname; $resolved = Join-Path $DeployedRoot $configured
|
||||
$entry = $inventory | Where-Object Name -eq $configured | Select-Object -First 1
|
||||
[pscustomobject]@{
|
||||
Sequence = $sequence; ConfigFile = 'Settings.xml'; ConfiguredPath = $configured; ResolvedPath = ('<deployed-root>\' + $configured)
|
||||
Assembly = $entry.AssemblyName; Exists = Test-Path -LiteralPath $resolved; Sha256 = $entry.Sha256
|
||||
LoadMethod = 'acDoc.Editor.Command("._netload", fullPath)'; Required = $true
|
||||
FailureBehavior = 'Outer loader catch stops remaining sequence and shows MessageBox'; Notes = 'Path is combined with OrthoLoader assembly directory.'
|
||||
}
|
||||
}
|
||||
$configuration | Export-Csv -LiteralPath (Join-Path $OutputRoot 'configuration-map.csv') -NoTypeInformation -Encoding utf8
|
||||
|
||||
$projects = Get-ChildItem -LiteralPath $SourceRoot -Recurse -File -Filter *.csproj | ForEach-Object {
|
||||
$content = Get-Content -LiteralPath $_.FullName -Raw
|
||||
$assemblyName = if ($content -match '<AssemblyName>([^<]+)</AssemblyName>') { $Matches[1] } else { [IO.Path]::GetFileNameWithoutExtension($_.Name) }
|
||||
$framework = if ($content -match '<TargetFrameworkVersion>([^<]+)</TargetFrameworkVersion>') { $Matches[1] } else { $null }
|
||||
$platform = if ($content -match '<PlatformTarget>([^<]+)</PlatformTarget>') { $Matches[1] } else { $null }
|
||||
[pscustomobject]@{ AssemblyName = $assemblyName; Csproj = $_.FullName.Substring($SourceRoot.Length).TrimStart('\'); TargetFramework = $framework; PlatformTarget = $platform }
|
||||
}
|
||||
$sourceMap = foreach ($dll in $inventory | Where-Object Extension -eq '.dll') {
|
||||
$matches = @($projects | Where-Object { $_.AssemblyName -eq $dll.AssemblyName -or $_.AssemblyName -eq [IO.Path]::GetFileNameWithoutExtension($dll.Name) })
|
||||
$releaseCandidate = Join-Path (Join-Path $SourceRoot 'FilesRelease') $dll.Name
|
||||
$exactReleaseMatch = (Test-Path -LiteralPath $releaseCandidate) -and ((Get-FileHash -LiteralPath $releaseCandidate -Algorithm SHA256).Hash -eq $dll.Sha256)
|
||||
$status = if ($exactReleaseMatch) { 'EXACT_SOURCE_MATCH' } elseif ($matches.Count -eq 1) { 'LIKELY_SOURCE_MATCH' } elseif ($matches.Count -gt 1) { 'MULTIPLE_CANDIDATES' } elseif ($dll.Name -match '^PnP|^Ac') { 'AUTODESK_DEPENDENCY' } else { 'NO_SOURCE_FOUND' }
|
||||
$project = $matches | Select-Object -First 1
|
||||
[pscustomobject]@{ DeployedDll=$dll.Name; DeployedSha256=$dll.Sha256; FileVersion=$dll.FileVersion; AssemblyVersion=$dll.AssemblyVersion; LegacyProject=if($project){[IO.Path]::GetFileNameWithoutExtension($project.Csproj)}else{$null}; LegacyCsproj=if($project){$project.Csproj}else{$null}; LegacyOutputName=if($project){$project.AssemblyName}else{$null}; TargetFramework=if($project){$project.TargetFramework}else{$null}; PlatformTarget=if($project){$project.PlatformTarget}else{$null}; CommandCount=$null; RibbonRole=if($dll.AssemblyName -eq 'OrthoLoader'){'Central ribbon/loader'}else{$null}; ConfigurationSource=if($configuration.ConfiguredPath -contains $dll.Name){'Settings.xml'}else{$null}; MatchStatus=$status }
|
||||
}
|
||||
$sourceMap | Sort-Object DeployedDll | Export-Csv -LiteralPath (Join-Path $OutputRoot 'deployed-to-source-map.csv') -NoTypeInformation -Encoding utf8
|
||||
|
||||
"GeneratedUtc=$([DateTime]::UtcNow.ToString('o'))`nDeployedRoot=<network root redacted to report context>`nSourceRoot=<network source root redacted to report context>`nFiles=$($inventory.Count)" | Set-Content -LiteralPath (Join-Path $OutputRoot 'evidence\generation.txt') -Encoding utf8
|
||||
@@ -1,19 +0,0 @@
|
||||
# Legacy P_Rec_Annot loading audit
|
||||
|
||||
Scope: read-only inspection of the deployed network directory and the v1.0.4 source checkout. No legacy DLL was executed, copied, or deployed.
|
||||
|
||||
The deployed root has 52 files, including 22 managed DLLs. `Settings.xml` enumerates 20 application modules. The inventory and its SHA-256 values are in `deployed-files.csv`; generated report paths use `<deployed-root>` rather than embedding a new runtime configuration path.
|
||||
|
||||
High-confidence conclusion: the central loader is `OrthoLoader.dll`. `OrthoLoader.Ribbon.Initialize()` invokes `OrthoLoader.Initialize()`. The latter reads adjacent `Settings.xml` and calls `acDoc.Editor.Command("._netload", fullPath)` sequentially for the configured modules. The bootstrap that initially loads `OrthoLoader.dll` is not present in either audited directory; it remains unknown. Therefore the audit proves same-directory loading, but cannot prove whether an external launcher first NETLOADed the UNC copy or a local copy.
|
||||
|
||||
Source-to-binary mapping is `EXACT_SOURCE_MATCH` for 15 deployed DLLs by SHA-256 against `FilesRelease`. Six newer deployed DLLs are `LIKELY_SOURCE_MATCH` by assembly/project identity and metadata but differ byte-for-byte from the checked-in release output. `InsertElevation.dll` is `MULTIPLE_CANDIDATES` because archival projects share the assembly identity.
|
||||
|
||||
## Potential test data
|
||||
|
||||
| RelativePath | Type | LikelyPurpose | ContainsProjectData | SafeForTest | Reason |
|
||||
|---|---|---|---|---|---|
|
||||
| `Template.dwg` | DWG | legacy command template | Unknown | No | Located beside production deployment; no authorization to open or copy it was established. |
|
||||
| `test.txt` | text | unknown/empty marker | No content | Not useful | Zero bytes. |
|
||||
| `Справка*.pdf/docx` | documentation | user instructions | Unknown | Review only | Not a CAD fixture. |
|
||||
|
||||
No `Project.xml`, `.dcf`, or explicitly labelled sample/demo Ortho DWG was found in the deployed root.
|
||||
@@ -1,21 +0,0 @@
|
||||
"Sequence","ConfigFile","ConfiguredPath","ResolvedPath","Assembly","Exists","Sha256","LoadMethod","Required","FailureBehavior","Notes"
|
||||
"1","Settings.xml","AnnotateLineNumbers.dll","<deployed-root>\AnnotateLineNumbers.dll","AnnotateLineNumbers","True","799BC277ED98BD870F01824180B5A945B24F016DFD9956A8CCC630AFE80FDFA4","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"2","Settings.xml","AutoAnnotateView.dll","<deployed-root>\AutoAnnotateView.dll","AutoAnnotateView","True","EAE55411EC84AC747A3816EE65CD095648282BE44235351E7C9ED1C188BCB611","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"3","Settings.xml","AutoLinenumberConnections.dll","<deployed-root>\AutoLinenumberConnections.dll","AutoLinenumberConnections","True","0F6B2A759F2A63B9152B480B6968BDA20F42AD1D922F67CA6E6BFD31A0715F35","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"4","Settings.xml","CheckOrthoView.dll","<deployed-root>\CheckOrthoView.dll","CheckOrthoView","True","89DB5E537C2BAEC292CDD8A0A43819119850119D54958E8CC3C43E2136AB24A0","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"5","Settings.xml","EnablerLoader.dll","<deployed-root>\EnablerLoader.dll","EnablerLoader","True","745D625C27F92842F2EFF3C515DB48BB9FC0FE59B6E4559ACD696BCC93FF4C51","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"6","Settings.xml","ExtentsIntersect.dll","<deployed-root>\ExtentsIntersect.dll","ExtentsIntersect","True","59D2776E083D4BE2FBFBE4F43009EAA9CE0E81A1752815B45C510F80654169D9","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"7","Settings.xml","GetComponentGeometry.dll","<deployed-root>\GetComponentGeometry.dll","GetComponentGeometry","True","9EA8C48FEC4D36D32CC6AC88B78C434662C16D4484F86EFAA46ECC309E9F9142","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"8","Settings.xml","GetLinenumberAnnotationText.dll","<deployed-root>\GetLinenumberAnnotationText.dll","GetLinenumberAnnotationText","True","047E721DBE380061D9ABDD92BC5475440E3B7ADB2BD0F544D538BA24B121D30E","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"9","Settings.xml","GetPlantScriptFolderPath.dll","<deployed-root>\GetPlantScriptFolderPath.dll","GetPlantScriptFolderPath","True","5080A167A1DAF6C19BE08F6CAB678D490736B5E07E121A918A2915F0D9D8BF65","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"10","Settings.xml","GetScriptParameters.dll","<deployed-root>\GetScriptParameters.dll","GetScriptParameters","True","7FF7B0D26BE8D89FF4A5A4FA1C6EC17BDA1506BCCB78AFE0E650137CD7BA5E87","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"11","Settings.xml","InsertElevation.dll","<deployed-root>\InsertElevation.dll","InsertElevation","True","B2D8B98F353D3DDF3A75A848E6922DC30478A1E5F98B372F24A049690492CA82","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"12","Settings.xml","LoadedAssemblies.dll","<deployed-root>\LoadedAssemblies.dll","LoadedAssemblies","True","277EB83C587A984DC2F558B8A7C86AA98E195A77AFA6FADD7DB83F2B801C0E4E","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"13","Settings.xml","OrthoAutoAnnotate.dll","<deployed-root>\OrthoAutoAnnotate.dll","OrthoAutoAnnotate","True","2E6BB2EBA0A98AA3550DFA4E661832FC7C3EE3DFF49BFC147BD4DFAD7A8596C6","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"14","Settings.xml","P3DCopyMissingObjectsToOrthoDrawing.dll","<deployed-root>\P3DCopyMissingObjectsToOrthoDrawing.dll","P3DCopyMissingObjectsToOrthoDrawing","True","6C879AFC7F35EC25E5E2FFF9FF0EE6EFEB155670DBFD5D0CDE9A0F629C5727B2","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"15","Settings.xml","P3DNewMa.dll","<deployed-root>\P3DNewMa.dll","P3DNewMa","True","97246EE9B24FD373551EF380C5AC0CA78CF7ED6A92581D5331B54445A2A948F8","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"16","Settings.xml","P3DSetLayerForViewport.dll","<deployed-root>\P3DSetLayerForViewport.dll","P3DSetLayerForViewport","True","A28E58FDE4EBAA26450CA646C5A85F98C1A04AA42C0BFC5413D718243ACBF42B","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"17","Settings.xml","PHPAnnotations.dll","<deployed-root>\PHPAnnotations.dll","PHPAnnotations","True","816A693F1B775C5506043DD0461055F248D3897E96AC8285DAE29C5B71FFCA73","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"18","Settings.xml","SwapAttributesMenuItem.dll","<deployed-root>\SwapAttributesMenuItem.dll","SwapAttributesMenuItem","True","674C865E34F9C877561E03E2077F7F2C6852C3C8B6A0503ACAA7619F3E80A460","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"19","Settings.xml","UpdateOrthoDrawings.dll","<deployed-root>\UpdateOrthoDrawings.dll","UpdateOrthoDrawings","True","1492FFF1F56853DF4A858CD3E926640C1E4920B5E3F5CFCC12F4389855E630BA","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
"20","Settings.xml","UpdateOrthoViewBlocks.dll","<deployed-root>\UpdateOrthoViewBlocks.dll","UpdateOrthoViewBlocks","True","5022D4D66D79E7E7D82F9FCB647DF8E1FE658A4694EB7794E363D5EA5517F5A9","acDoc.Editor.Command(""._netload"", fullPath)","True","Outer loader catch stops remaining sequence and shows MessageBox","Path is combined with OrthoLoader assembly directory."
|
||||
|
@@ -1,53 +0,0 @@
|
||||
"RelativePath","Name","Extension","Length","LastWriteTimeUtc","Sha256","FileVersion","ProductVersion","AssemblyName","AssemblyVersion","TargetFramework","PeArchitecture"
|
||||
"AnnotateLineNumbers.dll","AnnotateLineNumbers.dll",".dll","126976","2024-12-13T07:41:34.0000000Z","799BC277ED98BD870F01824180B5A945B24F016DFD9956A8CCC630AFE80FDFA4","1.0.4.1","v1.0.4","AnnotateLineNumbers","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"AutoAnnotateView.dll","AutoAnnotateView.dll",".dll","49664","2025-02-10T07:47:43.4844455Z","EAE55411EC84AC747A3816EE65CD095648282BE44235351E7C9ED1C188BCB611","1.0.4.6","v1.0.4","AutoAnnotateView","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"AutoElevation.dll","AutoElevation.dll",".dll","28160","2025-01-16T08:13:32.2554341Z","A8766B73B30F8F44213769EB7824F82F73ED418DE0EF49695E00A3EB7FC28885","1.0.4.5","v1.0.4","AutoElevation","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"AutoLinenumberConnections.dll","AutoLinenumberConnections.dll",".dll","41472","2024-12-13T07:41:34.0000000Z","0F6B2A759F2A63B9152B480B6968BDA20F42AD1D922F67CA6E6BFD31A0715F35","1.0.4.1","v1.0.4","AutoLinenumberConnections","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"b455ee6eaed79d76f084846a60ce3284","b455ee6eaed79d76f084846a60ce3284","","0","2023-12-29T11:13:44.6409171Z","E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",,,,,,
|
||||
"CheckOrthoView.dll","CheckOrthoView.dll",".dll","14848","2024-12-13T07:41:34.0000000Z","89DB5E537C2BAEC292CDD8A0A43819119850119D54958E8CC3C43E2136AB24A0","1.0.4.1","v1.0.4","CheckOrthoView","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"EnablerLoader.dll","EnablerLoader.dll",".dll","6144","2024-12-13T07:41:34.0000000Z","745D625C27F92842F2EFF3C515DB48BB9FC0FE59B6E4559ACD696BCC93FF4C51","1.0.4.1","v1.0.4","EnablerLoader","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"ExtentsIntersect.dll","ExtentsIntersect.dll",".dll","6656","2024-12-13T07:41:34.0000000Z","59D2776E083D4BE2FBFBE4F43009EAA9CE0E81A1752815B45C510F80654169D9","1.0.4.1","v1.0.4","ExtentsIntersect","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"GetComponentGeometry.dll","GetComponentGeometry.dll",".dll","10240","2024-12-13T07:41:34.0000000Z","9EA8C48FEC4D36D32CC6AC88B78C434662C16D4484F86EFAA46ECC309E9F9142","1.0.4.1","v1.0.4","GetComponentGeometry","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"GetComponentGeometry.xml","GetComponentGeometry.xml",".xml","1239","2025-01-14T11:26:24.5054138Z","70169C3A3AAAFE5A2282E01D5B9B7C99F2F4FF9B7514553434302674ABE46C98",,,,,,
|
||||
"GetLinenumberAnnotationText.dll","GetLinenumberAnnotationText.dll",".dll","23040","2024-12-13T07:41:34.0000000Z","047E721DBE380061D9ABDD92BC5475440E3B7ADB2BD0F544D538BA24B121D30E","1.0.4.1","v1.0.4","GetLinenumberAnnotationText","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"GetPlantScriptFolderPath.dll","GetPlantScriptFolderPath.dll",".dll","4608","2024-12-13T07:41:34.0000000Z","5080A167A1DAF6C19BE08F6CAB678D490736B5E07E121A918A2915F0D9D8BF65","1.0.4.1","v1.0.4","GetPlantScriptFolderPath","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"GetScriptParameters.dll","GetScriptParameters.dll",".dll","6656","2024-12-13T07:41:34.0000000Z","7FF7B0D26BE8D89FF4A5A4FA1C6EC17BDA1506BCCB78AFE0E650137CD7BA5E87","1.0.4.1","v1.0.4","GetScriptParameters","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"ico\button_BJ.png","button_BJ.png",".png","1235","2024-12-13T07:41:34.0000000Z","414F0A23E917D41A82C697B0E9CDDDF9BFCACD1627310BD5717AEA172418AA51",,,,,,
|
||||
"ico\button_CAC.png","button_CAC.png",".png","1943","2024-12-13T07:41:34.0000000Z","FA2B8877F8B41C70BA62C737C40374394D6840A53DFE2D0B0F83463C4817C138",,,,,,
|
||||
"ico\button_PXP-AAV.png","button_PXP-AAV.png",".png","1905","2024-12-13T07:41:34.0000000Z","6AB171963B553B434B5AA4BFBE91993B1782606AAF9B486FA23BB27BC1F0E4AA",,,,,,
|
||||
"ico\button_PXP-AC.png","button_PXP-AC.png",".png","1751","2024-12-13T07:41:34.0000000Z","60A6BD62EF3E50BDDCD00D9C281F2399E6C9D42E0C2DAA1742273C0DC48FE9C3",,,,,,
|
||||
"ico\button_PXP-AE.png","button_PXP-AE.png",".png","2001","2024-12-13T07:41:34.0000000Z","F1DCF0E908F5AF38CCD13ABA58B824E5B186430414499C0F623C1D5EEAD8DAD7",,,,,,
|
||||
"ico\button_PXP-ALC.png","button_PXP-ALC.png",".png","1740","2024-12-13T07:41:34.0000000Z","19DB2449B66B95965155E6E3BC313BD52000FE38C95B0FAD90EF07DF53EA9CC9",,,,,,
|
||||
"ico\button_PXP-COV.png","button_PXP-COV.png",".png","2007","2024-12-13T07:41:34.0000000Z","529BDD045961C244FBD0DE177B899AF9E7CE0F4506B3463C56D2704B3BF207B5",,,,,,
|
||||
"ico\button_PXP-DAV.png","button_PXP-DAV.png",".png","1249","2025-01-28T06:29:03.3724433Z","A7932F2ABDAC652EA3A901A221AB3BE2BC80FCB76FB203D44FC63AF3681F51E3",,,,,,
|
||||
"ico\button_PXP-DJ.png","button_PXP-DJ.png",".png","1470","2024-12-13T07:41:34.0000000Z","127957752DC2B6573CD2FD9A2791914BA22802E07E9B7E23CD3AE1FFF9E4FFC4",,,,,,
|
||||
"ico\button_PXP-ELN.png","button_PXP-ELN.png",".png","1927","2024-12-13T07:41:34.0000000Z","DF32E6A465D5C196A17F3F96473315590DF2D71995F63D2D241D7599B62C99BB",,,,,,
|
||||
"ico\button_PXP-ESN.png","button_PXP-ESN.png",".png","1636","2024-12-13T07:41:34.0000000Z","0F089184CFD428DA3620F9C0AD2DCBFC21B31ED34FA301E8D5D45923783C0CC7",,,,,,
|
||||
"ico\button_PXP-IE.png","button_PXP-IE.png",".png","1885","2024-12-13T07:41:34.0000000Z","F1C6373BFAF82E1908AF371D5F21133D27E0B84AFC706A703530855A2046FE43",,,,,,
|
||||
"ico\button_PXP-IOD.png","button_PXP-IOD.png",".png","1942","2024-12-13T07:41:34.0000000Z","494E90C899A0BB5BB365B0E1452E92A68359610C5A38A37D09F520A39BDB3899",,,,,,
|
||||
"ico\button_PXP-LN.png","button_PXP-LN.png",".png","2024","2024-12-13T07:41:34.0000000Z","F3305ED6D266E29FA475D4C8A7DF38D2D2A1776A4CE379B876B6CFA278B2FE5D",,,,,,
|
||||
"ico\button_PXP-MA.png","button_PXP-MA.png",".png","1907","2024-12-13T07:41:34.0000000Z","9386249121644DCF4B9C70491F561E8B6E5BAAE87EBB2C76A84E46B10A60C8C3",,,,,,
|
||||
"ico\button_PXP-OA.png","button_PXP-OA.png",".png","1999","2024-12-13T07:41:34.0000000Z","EAA2AD7EE5C6B7F3716AF1D7690C76AE59229AE9B6BA02009F5009CD9307F4E8",,,,,,
|
||||
"ico\button_PXP-OSI.png","button_PXP-OSI.png",".png","1508","2024-12-13T07:41:34.0000000Z","422D10B6EBDD76085CF9C5985391CCA060464B200D6792D604658AAE47F12CEE",,,,,,
|
||||
"ico\button_PXP-RA.png","button_PXP-RA.png",".png","1826","2024-12-13T07:41:34.0000000Z","1DB3F39C6CCA4EABE79D81DE6DCF9900649CDC9064F2EBF5E45F9AD849B8317F",,,,,,
|
||||
"ico\button_PXP-RMP.png","button_PXP-RMP.png",".png","1759","2024-12-13T07:41:34.0000000Z","156026EC6DEB7BDBE720047409E9A25615D650D8634C2F0BAF49A71E27135258",,,,,,
|
||||
"ico\button_PXP-ULN.png","button_PXP-ULN.png",".png","1928","2024-12-13T07:41:34.0000000Z","B37EBB460691EAA7BC3A91DF05C27FE7CC1F0E9F9D69BDA7703956EAA7AA785C",,,,,,
|
||||
"ico\button_PXP-UOB.png","button_PXP-UOB.png",".png","1569","2024-12-13T07:41:34.0000000Z","3BC16B9923CCD34CF10F403CD65732B217E255E89539174841E7F30D9DFD2DCD",,,,,,
|
||||
"InsertElevation.dll","InsertElevation.dll",".dll","24576","2025-01-16T08:13:32.2504470Z","B2D8B98F353D3DDF3A75A848E6922DC30478A1E5F98B372F24A049690492CA82","1.0.4.5","v1.0.4","InsertElevation","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"LoadedAssemblies.dll","LoadedAssemblies.dll",".dll","4608","2024-12-13T07:41:34.0000000Z","277EB83C587A984DC2F558B8A7C86AA98E195A77AFA6FADD7DB83F2B801C0E4E","1.0.4.1","v1.0.4","LoadedAssemblies","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"OrthoAutoAnnotate.dll","OrthoAutoAnnotate.dll",".dll","108544","2025-02-10T07:45:33.3280516Z","2E6BB2EBA0A98AA3550DFA4E661832FC7C3EE3DFF49BFC147BD4DFAD7A8596C6","1.0.4.6","v1.0.4","OrthoAutoAnnotate","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"OrthoLoader.dll","OrthoLoader.dll",".dll","18944","2025-02-24T06:59:01.9094468Z","D418F2CBDD134C0156E2D332BD7F9ED471B9A1E0581FE42EDF98BBE01CB3345D","1.0.4.7","v1.0.4","OrthoLoader","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"P3DCopyMissingObjectsToOrthoDrawing.dll","P3DCopyMissingObjectsToOrthoDrawing.dll",".dll","14336","2024-12-13T07:41:34.0000000Z","6C879AFC7F35EC25E5E2FFF9FF0EE6EFEB155670DBFD5D0CDE9A0F629C5727B2","1.0.4.1","v1.0.4","P3DCopyMissingObjectsToOrthoDrawing","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"P3DNewMa.dll","P3DNewMa.dll",".dll","103936","2025-02-24T06:59:03.1350320Z","97246EE9B24FD373551EF380C5AC0CA78CF7ED6A92581D5331B54445A2A948F8","1.0.4.7","v1.0.4","P3DNewMa","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"P3DSetLayerForViewport.dll","P3DSetLayerForViewport.dll",".dll","4608","2024-12-13T07:41:34.0000000Z","A28E58FDE4EBAA26450CA646C5A85F98C1A04AA42C0BFC5413D718243ACBF42B","1.0.4.1","v1.0.4","P3DSetLayerForViewport","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"PHPAnnotations.dll","PHPAnnotations.dll",".dll","69120","2024-12-13T07:41:34.0000000Z","816A693F1B775C5506043DD0461055F248D3897E96AC8285DAE29C5B71FFCA73","1.0.4.1","v1.0.4","PHPAnnotations","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"Settings.xml","Settings.xml",".xml","136576","2025-01-31T07:28:24.1891507Z","AFEDC7526923ACE1DC60374F98F171809B36992262A37E1F11828F2B8D9799CB",,,,,,
|
||||
"SwapAttributesMenuItem.dll","SwapAttributesMenuItem.dll",".dll","11264","2024-12-13T07:41:34.0000000Z","674C865E34F9C877561E03E2077F7F2C6852C3C8B6A0503ACAA7619F3E80A460","1.0.4.1","v1.0.4","SwapAttributesMenuItem","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"Template.dwg","Template.dwg",".dwg","148438","2025-01-30T13:16:13.0000000Z","EA279A30F113660CF53585451083BAEB0A01CA58C8F9A767C4B7C2A5E66ECCA3",,,,,,
|
||||
"test.txt","test.txt",".txt","0","2025-01-16T09:56:52.0597137Z","E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",,,,,,
|
||||
"UpdateOrthoDrawings.dll","UpdateOrthoDrawings.dll",".dll","45568","2025-02-24T06:59:02.4829428Z","1492FFF1F56853DF4A858CD3E926640C1E4920B5E3F5CFCC12F4389855E630BA","1.0.4.2","v1.0.4","UpdateOrthoDrawings","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"UpdateOrthoViewBlocks.dll","UpdateOrthoViewBlocks.dll",".dll","15360","2024-12-13T07:41:34.0000000Z","5022D4D66D79E7E7D82F9FCB647DF8E1FE658A4694EB7794E363D5EA5517F5A9","1.0.4.1","v1.0.4","UpdateOrthoViewBlocks","1.0.4.0",".NETFramework,Version=v4.7","x64"
|
||||
"Справка по работе с палитрой.docx","Справка по работе с палитрой.docx",".docx","855301","2025-02-05T09:53:08.1186958Z","B6DC65BA17C4EA79DBCCC501909BEDBF56EB6A9F1E24BFAD1B01158B6EC639D0",,,,,,
|
||||
"Справка по работе с палитрой.pdf","Справка по работе с палитрой.pdf",".pdf","744035","2025-02-05T09:53:21.3752687Z","332CA70D3264C28BC712CA790157D0CD03BE7E07F42603DAF9247600271B7B75",,,,,,
|
||||
"Справка.docx","Справка.docx",".docx","87889","2025-02-06T05:13:36.4991443Z","14BA9BD252FB6DF37C4D151787E8799AB1703847C1DEC24F3EFE4D6805B0EB12",,,,,,
|
||||
"Справка.pdf","Справка.pdf",".pdf","154074","2025-02-06T05:14:24.2809131Z","EB840514755A9F56ABC8351B8EC41233FE2512118DB2D4BA0ACD46E74D2C7DC9",,,,,,
|
||||
|
@@ -1,23 +0,0 @@
|
||||
"DeployedDll","DeployedSha256","FileVersion","AssemblyVersion","LegacyProject","LegacyCsproj","LegacyOutputName","TargetFramework","PlatformTarget","CommandCount","RibbonRole","ConfigurationSource","MatchStatus"
|
||||
"AnnotateLineNumbers.dll","799BC277ED98BD870F01824180B5A945B24F016DFD9956A8CCC630AFE80FDFA4","1.0.4.1","1.0.4.0","AnnotateLineNumbers","AnnotateLineNumbers\AnnotateLineNumbers.csproj","AnnotateLineNumbers","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"AutoAnnotateView.dll","EAE55411EC84AC747A3816EE65CD095648282BE44235351E7C9ED1C188BCB611","1.0.4.6","1.0.4.0","AutoAnnotateView","AutoAnnotateView\AutoAnnotateView.csproj","AutoAnnotateView","v4.7","x64",,,"Settings.xml","LIKELY_SOURCE_MATCH"
|
||||
"AutoElevation.dll","A8766B73B30F8F44213769EB7824F82F73ED418DE0EF49695E00A3EB7FC28885","1.0.4.5","1.0.4.0","AutoElevation","AutoElevation\AutoElevation.csproj","AutoElevation","v4.7","x64",,,,"LIKELY_SOURCE_MATCH"
|
||||
"AutoLinenumberConnections.dll","0F6B2A759F2A63B9152B480B6968BDA20F42AD1D922F67CA6E6BFD31A0715F35","1.0.4.1","1.0.4.0","AutoLinenumberConnections","AutoLinenumberConnections\AutoLinenumberConnections.csproj","AutoLinenumberConnections","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"CheckOrthoView.dll","89DB5E537C2BAEC292CDD8A0A43819119850119D54958E8CC3C43E2136AB24A0","1.0.4.1","1.0.4.0","CheckOrthoView","CheckOrthoView\CheckOrthoView.csproj","CheckOrthoView","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"EnablerLoader.dll","745D625C27F92842F2EFF3C515DB48BB9FC0FE59B6E4559ACD696BCC93FF4C51","1.0.4.1","1.0.4.0","EnablerLoader","EnablerLoader\EnablerLoader.csproj","EnablerLoader","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"ExtentsIntersect.dll","59D2776E083D4BE2FBFBE4F43009EAA9CE0E81A1752815B45C510F80654169D9","1.0.4.1","1.0.4.0","ExtentsIntersect","ExtentsIntersect\ExtentsIntersect.csproj","ExtentsIntersect","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"GetComponentGeometry.dll","9EA8C48FEC4D36D32CC6AC88B78C434662C16D4484F86EFAA46ECC309E9F9142","1.0.4.1","1.0.4.0","GetComponentGeometry","GetComponentGeometry\GetComponentGeometry.csproj","GetComponentGeometry","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"GetLinenumberAnnotationText.dll","047E721DBE380061D9ABDD92BC5475440E3B7ADB2BD0F544D538BA24B121D30E","1.0.4.1","1.0.4.0","GetLinenumberAnnotationText","GetLinenumberAnnotationText\GetLinenumberAnnotationText.csproj","GetLinenumberAnnotationText","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"GetPlantScriptFolderPath.dll","5080A167A1DAF6C19BE08F6CAB678D490736B5E07E121A918A2915F0D9D8BF65","1.0.4.1","1.0.4.0","GetPlantScriptFolderPath","GetPlantScriptFolderPath\GetPlantScriptFolderPath.csproj","GetPlantScriptFolderPath","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"GetScriptParameters.dll","7FF7B0D26BE8D89FF4A5A4FA1C6EC17BDA1506BCCB78AFE0E650137CD7BA5E87","1.0.4.1","1.0.4.0","GetScriptParameters","GetScriptParameters\GetScriptParameters.csproj","GetScriptParameters","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"InsertElevation.dll","B2D8B98F353D3DDF3A75A848E6922DC30478A1E5F98B372F24A049690492CA82","1.0.4.5","1.0.4.0","InsertElevation","InsertElevation\InsertElevation.csproj","InsertElevation","v4.7","x64",,,"Settings.xml","MULTIPLE_CANDIDATES"
|
||||
"LoadedAssemblies.dll","277EB83C587A984DC2F558B8A7C86AA98E195A77AFA6FADD7DB83F2B801C0E4E","1.0.4.1","1.0.4.0","LoadedAssemblies","LoadedAssemblies\LoadedAssemblies.csproj","LoadedAssemblies","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"OrthoAutoAnnotate.dll","2E6BB2EBA0A98AA3550DFA4E661832FC7C3EE3DFF49BFC147BD4DFAD7A8596C6","1.0.4.6","1.0.4.0","OrthoAutoAnnotate","OrthoAutoAnnotate\OrthoAutoAnnotate.csproj","OrthoAutoAnnotate","v4.7","x64",,,"Settings.xml","LIKELY_SOURCE_MATCH"
|
||||
"OrthoLoader.dll","D418F2CBDD134C0156E2D332BD7F9ED471B9A1E0581FE42EDF98BBE01CB3345D","1.0.4.7","1.0.4.0","OrthoLoader","OrthoLoader\OrthoLoader.csproj","OrthoLoader","v4.7","x64",,"Central ribbon/loader",,"LIKELY_SOURCE_MATCH"
|
||||
"P3DCopyMissingObjectsToOrthoDrawing.dll","6C879AFC7F35EC25E5E2FFF9FF0EE6EFEB155670DBFD5D0CDE9A0F629C5727B2","1.0.4.1","1.0.4.0","P3DCopyMissingObjectsToOrthoDrawing","P3DCopyMissingObjectsToOrthoDrawing\P3DCopyMissingObjectsToOrthoDrawing.csproj","P3DCopyMissingObjectsToOrthoDrawing","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"P3DNewMa.dll","97246EE9B24FD373551EF380C5AC0CA78CF7ED6A92581D5331B54445A2A948F8","1.0.4.7","1.0.4.0","P3DNewMa","P3DNewMa\P3DNewMa.csproj","P3DNewMa","v4.7","x64",,,"Settings.xml","LIKELY_SOURCE_MATCH"
|
||||
"P3DSetLayerForViewport.dll","A28E58FDE4EBAA26450CA646C5A85F98C1A04AA42C0BFC5413D718243ACBF42B","1.0.4.1","1.0.4.0","P3DSetLayerForViewport","P3DSetLayerForViewport\P3DSetLayerForViewport.csproj","P3DSetLayerForViewport","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"PHPAnnotations.dll","816A693F1B775C5506043DD0461055F248D3897E96AC8285DAE29C5B71FFCA73","1.0.4.1","1.0.4.0","PHPAnnotations","PHPAnnotations\PHPAnnotations.csproj","PHPAnnotations","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"SwapAttributesMenuItem.dll","674C865E34F9C877561E03E2077F7F2C6852C3C8B6A0503ACAA7619F3E80A460","1.0.4.1","1.0.4.0","SwapAttributesMenuItem","SwapAttributesMenuItem\SwapAttributesMenuItem.csproj","SwapAttributesMenuItem","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
"UpdateOrthoDrawings.dll","1492FFF1F56853DF4A858CD3E926640C1E4920B5E3F5CFCC12F4389855E630BA","1.0.4.2","1.0.4.0","UpdateOrthoDrawings","UpdateOrthoDrawings\UpdateOrthoDrawings.csproj","UpdateOrthoDrawings","v4.7","x64",,,"Settings.xml","LIKELY_SOURCE_MATCH"
|
||||
"UpdateOrthoViewBlocks.dll","5022D4D66D79E7E7D82F9FCB647DF8E1FE658A4694EB7794E363D5EA5517F5A9","1.0.4.1","1.0.4.0","UpdateOrthoViewBlocks","UpdateOrthoViewBlocks\UpdateOrthoViewBlocks.csproj","UpdateOrthoViewBlocks","v4.7","x64",,,"Settings.xml","EXACT_SOURCE_MATCH"
|
||||
|
@@ -1,4 +0,0 @@
|
||||
GeneratedUtc=2026-07-31T10:39:04.9899963Z
|
||||
DeployedRoot=<network root redacted to report context>
|
||||
SourceRoot=<network source root redacted to report context>
|
||||
Files=52
|
||||
@@ -1,13 +0,0 @@
|
||||
# Legacy versus new loading model
|
||||
|
||||
| Legacy behavior | Legacy evidence | New implementation | Compatibility requirement | Decision | Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| Unknown external bootstrap loads `OrthoLoader.dll` | `loading-chain.md` | Bundle bootstrap not selected yet | Identify the workstation/project startup owner first | Defer loading-model decision | High |
|
||||
| 20 independent DLLs loaded in order | `Settings.xml`, `Loader.cs:63-81` | one entry assembly currently | Preserve required command availability, not the DLL count | Evaluate command/module migration individually | Medium |
|
||||
| DLLs loaded from OrthoLoader's directory (possibly UNC) | `Loader.cs:49,65`; bootstrap source unknown | local smoke bundle only | Decide offline/update policy with operations owner | Do not hard-code UNC in new code | High |
|
||||
| Settings.xml controls order | `Loader.cs:104-112` | MSBuild/bundle manifest currently | Preserve explicit dependency ordering where behavior requires it | Document, do not emulate blindly | Medium |
|
||||
| central PluginsOrtho Ribbon | `Ribbon.cs` | new Promhimproekt tab | Preserve actual command mapping where adopted | Separate UI migration decision | Medium |
|
||||
| project selection uses local app_netload.lsp | `Ribbon.cs:380-405` | no equivalent | Confirm security/project-selection policy | Do not reproduce hidden local control file | High |
|
||||
| network icons and documents | `Ribbon.cs:330-357`, deployed files | bundle resources | Bundle only licensed, necessary resources | Replace network runtime dependency | Medium |
|
||||
|
||||
Recommendation: do not choose AppLoader or a manual-NETLOAD model yet. First identify the missing external bootstrap and confirm whether `app_netload.lsp` is the corporate launcher contract. Once confirmed, use a single versioned entry assembly with explicit manifest/dependency validation; avoid sequential programmatic NETLOAD and UNC execution because the legacy chain is non-atomic and fails the remaining modules after one error.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Proven legacy loading chain
|
||||
|
||||
```text
|
||||
Unknown external bootstrap
|
||||
-> NETLOAD of deployed OrthoLoader.dll (not evidenced in audited directories)
|
||||
-> OrthoLoader.Ribbon.Initialize()
|
||||
-> subscribe ComponentManager.ItemInitialized
|
||||
-> OrthoLoader.Initialize()
|
||||
-> adjacent Settings.xml / Main/LoadAssemblies/LoadAssembly
|
||||
-> sequential acDoc.Editor.Command("._netload", fullPath) for 20 DLLs
|
||||
-> each DLL exposes CommandMethod commands
|
||||
-> Ribbon ItemInitialized creates PluginsOrtho tab and command buttons
|
||||
```
|
||||
|
||||
| Claim | Evidence | Confidence |
|
||||
|---|---|---|
|
||||
| Central loader is `OrthoLoader.dll` | `OrthoLoader/Ribbon.cs:151-156`; deployed DLL metadata and `Settings.xml` | High |
|
||||
| Module list is read from adjacent `Settings.xml` | `OrthoLoader/Loader.cs:104-112` | High |
|
||||
| Module loading is programmatic `._netload`, in XML order | `OrthoLoader/Loader.cs:63-81`; `configuration-map.csv` | High |
|
||||
| Full DLL path is loader directory + configured filename | `OrthoLoader/Loader.cs:49, 65` | High |
|
||||
| Modules load from the same directory as OrthoLoader | `Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)` in `Loader.cs:49,65` | High |
|
||||
| Modules loaded directly from UNC | true only if external bootstrap NETLOADed the deployed UNC copy; bootstrap is outside audit scope | Unknown |
|
||||
| Missing/failed module stops later loading and shows MessageBox | single outer try/catch around the loop, `Loader.cs:63-99` | High |
|
||||
| Ribbon owner is OrthoLoader | `OrthoLoader/Ribbon.cs:16-19,151-172,193-321` | High |
|
||||
| Initial bootstrap mechanism | no LISP/CUI/ARG/REG/PackageContents file found in either audited directory | Unknown |
|
||||
|
||||
`Assembly.LoadFrom` occurs inside several legacy command implementations for secondary dependencies. It is not the mechanism used by `OrthoLoader` to load its configured modules. `EnablerLoader` separately uses `Assembly.LoadFile` for the Plant enabler from the host base directory.
|
||||
|
||||
No `PackageContents.xml`, `*.bundle`, registry demand-load registration, LISP, CUI, MNL, script, batch, or PowerShell bootstrap was found in the audited locations. This proves absence only within audit scope; it does not prove the workstation bootstrap did not exist elsewhere.
|
||||
@@ -1,10 +0,0 @@
|
||||
Dependency,Category,Evidence,Requiredness,Notes
|
||||
Settings.xml,CONFIGURATION,OrthoLoader/Loader.cs:104-112,Required,Adjacent to each loaded assembly directory
|
||||
ico/*.png,RIBBON_RESOURCE,OrthoLoader/Ribbon.cs:330-357,Optional,Missing file yields null image
|
||||
app_netload.lsp,CONFIGURATION,OrthoLoader/Ribbon.cs:380-405,Implicit,Local workstation file gates project-specific Ribbon enablement
|
||||
annotation_settings.xml,REFERENCE_DATA,README.md and annotation modules,Project-dependent,Located in Plant project data area according to README
|
||||
users_indexes_list.xml,REFERENCE_DATA,README.md and annotation modules,Project-dependent,Located in Plant project data area according to README
|
||||
Template.dwg,TEMPLATE,Deployed inventory and legacy source references,Command-dependent,Do not treat deployed copy as approved test fixture
|
||||
Plant project XML/DWG,COMMAND_TIME,UpdateOrthoDrawings and annotation source,Required by relevant command,Out of deployed plugin root
|
||||
Update report CSV,LOGGING,UpdateOrthoDrawings/Commands.cs:1004,Optional,Written beside executing assembly by legacy command
|
||||
Help PDFs,RIBBON_RESOURCE,P3DNewMa/NewMaControl.cs:6214,Optional,Opened from assembly directory
|
||||
|
@@ -1,9 +0,0 @@
|
||||
# Observed user workflow
|
||||
|
||||
After an external bootstrap loaded `OrthoLoader.dll`, the loader created a `PluginsOrtho` Ribbon tab only when `isProjectCorrect` accepted the current Plant project. That predicate reads the first line of the local file `C:\AutoCAD User Support\Applications\app_netload.lsp` and compares its encoded project root with the active Plant project (`OrthoLoader/Ribbon.cs:380-405`).
|
||||
|
||||
`PluginsOrtho` has eight panels. Button IDs are commands; clicks call `Document.SendStringToExecute(command + "\n", ...)` (`Ribbon.cs:193-321, 526-529`). Examples: `PXP-AAV` is registered by `AutoAnnotateView/Commands.cs:546`; `PXP-OA` by `OrthoAutoAnnotate/Commands.cs:6444`.
|
||||
|
||||
The deployed `ico/*.png` files are runtime Ribbon resources. They are resolved from the loader assembly directory; in this deployment that is the UNC directory. The code has a null-image fallback when an icon is absent.
|
||||
|
||||
The tab and panels use stable IDs and lookup before creation. Button collections are appended when a missing panel is created; repeated project-open events can enable existing UI or create absent UI. The event is detached after first Ribbon initialization, but `CurrentProjectOpenedEvent` is retained for the process lifetime.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user