Compare commits

..
Author SHA1 Message Date
codex b33351117b audit: clarify legacy bootstrap evidence 2026-07-31 13:39:55 +03:00
codex 56dcce42f6 audit: document legacy P_Rec_Annot loading chain 2026-07-31 13:39:29 +03:00
codex ea3a9e157e test: cover deterministic Ortho planning 2026-07-31 13:08:22 +03:00
codex 837ac1d994 feat: read Plant 3D Ortho context and viewports 2026-07-31 13:08:16 +03:00
codex 142b5f8b93 test: add controlled Plant 3D 2026 runtime smoke mode 2026-07-31 12:55:22 +03:00
codex dcf95d7e3a test: validate Plant 3D 2026 build and bundle 2026-07-31 11:37:36 +03:00
codex c733a27cbb feat: register real Plant 3D 2026 commands and ribbon 2026-07-31 11:37:31 +03:00
codex 9a7a6cd678 fix: bind P_Rec_Annot to Plant 3D 2026 assemblies 2026-07-31 11:36:46 +03:00
codex 66e52e5f50 chore: accept user-approved report presentation updates
Accept the three existing user changes as approved by the project owner. The changes are intentionally committed as-is without semantic normalization or regeneration.
2026-07-31 10:03:39 +03:00
codex 60b3934cd2 feat: scaffold P_Rec_Annot .NET 8 architecture 2026-07-31 09:57:53 +03:00
codex 34222cedc8 audit: inventory P_Rec_Annot v1.0.4 sources 2026-07-31 09:37:32 +03:00
codex 596c95a4b6 fix: aggregate Plant3D plugin usage without computer field 2026-07-31 09:07:06 +03:00
codex 6f29a5bce4 audit: parse Plant3D usage from Excel reports 2026-07-31 08:45:35 +03:00
codex 871499fa03 audit: build plugin usage matrix from logs 2026-07-31 08:00:19 +03:00
codex bbe87611bf audit: inventory P_Rec_BOP Plant 3D 2019 sources 2026-07-31 07:34:41 +03:00
codex 25e1def353 fix: make STA Word queue lifetime safe 2026-07-30 16:09:15 +03:00
codex f7fa0363e3 feat: add learned document template workflow 2026-07-30 15:24:51 +03:00
codex 11e6e5d806 fix: import DOCX body without source layout 2026-07-30 14:24:08 +03:00
codex 77a62d4564 fix: restore DOCX workbench runtime path 2026-07-30 14:00:31 +03:00
codex dab90f0780 test: keep COM integration validation explicit 2026-07-30 13:20:06 +03:00
codex 3f2fbe9883 test: add workbench tests and MVP documentation 2026-07-30 13:19:04 +03:00
codex 86fb8c89d2 feat: add standards workbench and portable rules profiles 2026-07-30 13:18:59 +03:00
codex 958bad6823 feat: add DOCX workbench and Word automation 2026-07-30 13:18:54 +03:00
codex 1bda2c155d feat: scaffold document automation workbench solution 2026-07-30 13:18:49 +03:00
codex 287ddff438 audit: classify Plant 3D plugin purpose and 2026 replacements 2026-07-30 11:10:49 +03:00
211 changed files with 59560 additions and 649 deletions
+11
View File
@@ -0,0 +1,11 @@
/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
+9
View File
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
+206
View File
@@ -0,0 +1,206 @@
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
+15
View File
@@ -0,0 +1,15 @@
# 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`.
+9
View File
@@ -0,0 +1,9 @@
# 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.
+9
View File
@@ -0,0 +1,9 @@
# 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`.
@@ -0,0 +1,7 @@
# 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.
@@ -0,0 +1,5 @@
# 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.
@@ -0,0 +1,14 @@
# 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.
@@ -0,0 +1,5 @@
# 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.
View File
+3
View File
@@ -0,0 +1,3 @@
# Samples
Реальные проектные DOCX/PDF и нормативные документы сюда не добавляются. Тесты создают синтетические документы во временном каталоге. Для локальной проверки укажите входные файлы через GUI.
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,171 @@
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;
}
@@ -0,0 +1,153 @@
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; }
}
@@ -0,0 +1,18 @@
<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>
@@ -0,0 +1,71 @@
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 }); }
}
@@ -0,0 +1,16 @@
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());
}
}
@@ -0,0 +1,73 @@
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))!);
}
}
@@ -0,0 +1,13 @@
<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>
@@ -0,0 +1,77 @@
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);
}
@@ -0,0 +1,15 @@
<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>
@@ -0,0 +1,73 @@
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 { } }
}
@@ -0,0 +1,80 @@
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(); }
}
@@ -0,0 +1,14 @@
<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>
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("DocxWorkbench.Tests")]
@@ -0,0 +1,60 @@
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.
}
}
@@ -0,0 +1,49 @@
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(); }
}
@@ -0,0 +1,42 @@
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(); }
}
@@ -0,0 +1,132 @@
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" };
}
}
@@ -0,0 +1,22 @@
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}"; }
}
@@ -0,0 +1,16 @@
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());
}
}
@@ -0,0 +1,18 @@
<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>
@@ -0,0 +1,77 @@
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 };
}
}
@@ -0,0 +1,11 @@
<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>
@@ -0,0 +1,17 @@
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; }
}
}
@@ -0,0 +1,11 @@
<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>
@@ -0,0 +1,33 @@
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", []));
}
@@ -0,0 +1,15 @@
<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>
@@ -0,0 +1,25 @@
<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>
@@ -0,0 +1,72 @@
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; }
}
@@ -0,0 +1,54 @@
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;
}
}
@@ -0,0 +1,25 @@
<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>
@@ -0,0 +1,22 @@
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);
}
@@ -0,0 +1,25 @@
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)));
}
@@ -0,0 +1,14 @@
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; }
}
@@ -0,0 +1,26 @@
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);
}
}
@@ -0,0 +1,25 @@
<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>
+6
View File
@@ -0,0 +1,6 @@
artifacts/
.local-dotnet-sdk/
.local-tools/
bin/
obj/
.vs/
@@ -0,0 +1,13 @@
<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>
+54
View File
@@ -0,0 +1,54 @@
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
+24
View File
@@ -0,0 +1,24 @@
# 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.
@@ -0,0 +1,8 @@
<?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>
@@ -0,0 +1,3 @@
# 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.
+29
View File
@@ -0,0 +1,29 @@
# 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.
@@ -0,0 +1,18 @@
# 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.
@@ -0,0 +1,24 @@
{
"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."
}
@@ -0,0 +1,26 @@
{
"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
}
@@ -0,0 +1,3 @@
# 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.
@@ -0,0 +1,5 @@
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 RuleId TargetClass TargetMethod LegacyFile LegacyLines Behavior MigrationDecision TestName
2 RULE-001 AnnotationRuleEngine BuildPlan AutoAnnotateView/Commands.cs 476-500 Remove prior annotations then insert calculated annotations REIMPLEMENT_SAME_BEHAVIOR ExistingAnnotation_IsUpdatedNotDuplicated
3 RULE-002 AnnotationRuleEngine BuildPlan OrthoAutoAnnotate/Commands.cs 6444-6488 Require Plant project and registered Ortho drawing KEEP_AFTER_ISOLATION PlantProjectAndOrthoDrawingRequired
4 RULE-003 PlantObjectReader ReadObjects README.md 96-101 Read EngineeringItems and line-group relationships through PnP API REIMPLEMENT_SAME_BEHAVIOR PlantAdapter_ReadsGoldenObjects
5 RULE-004 CommandRegistry Foundation OrthoLoader/Ribbon.cs 109-129 Expose annotation commands in annotation panel REIMPLEMENT_SAME_BEHAVIOR AllActiveCommands_AreMapped
@@ -0,0 +1,18 @@
# 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.
@@ -0,0 +1,15 @@
# 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 |
@@ -0,0 +1,9 @@
# 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.
@@ -0,0 +1,9 @@
# 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.
@@ -0,0 +1,31 @@
# 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.
@@ -0,0 +1,19 @@
# 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`.
@@ -0,0 +1,212 @@
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);
}
@@ -0,0 +1,3 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
</Project>
@@ -0,0 +1,36 @@
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" }
});
}
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
<ItemGroup><ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,109 @@
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];
}
}
@@ -0,0 +1,26 @@
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
});
}
}
@@ -0,0 +1,8 @@
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");
}
@@ -0,0 +1,23 @@
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 } }
};
}
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
<ItemGroup><ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,195 @@
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;
}
}
@@ -0,0 +1,20 @@
<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>
@@ -0,0 +1,14 @@
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)}") { }
@@ -0,0 +1,206 @@
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");
}
}
@@ -0,0 +1,79 @@
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(); }
}
@@ -0,0 +1,14 @@
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);
}
}
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
<ItemGroup><ProjectReference Include="..\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,4 @@
<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>
@@ -0,0 +1,17 @@
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;
}
@@ -0,0 +1,146 @@
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) } };
}
@@ -0,0 +1,6 @@
<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>
@@ -0,0 +1,11 @@
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.'
@@ -0,0 +1,13 @@
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.'
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,19 @@
# 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.
@@ -0,0 +1,21 @@
"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 Sequence ConfigFile ConfiguredPath ResolvedPath Assembly Exists Sha256 LoadMethod Required FailureBehavior Notes
2 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.
3 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.
4 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.
5 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.
6 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.
7 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.
8 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.
9 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.
10 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.
11 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.
12 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.
13 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.
14 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.
15 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.
16 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.
17 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.
18 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.
19 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.
20 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.
21 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.
@@ -0,0 +1,53 @@
"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 RelativePath Name Extension Length LastWriteTimeUtc Sha256 FileVersion ProductVersion AssemblyName AssemblyVersion TargetFramework PeArchitecture
2 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
3 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
4 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
5 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
6 b455ee6eaed79d76f084846a60ce3284 b455ee6eaed79d76f084846a60ce3284 0 2023-12-29T11:13:44.6409171Z E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855
7 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
8 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
9 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
10 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
11 GetComponentGeometry.xml GetComponentGeometry.xml .xml 1239 2025-01-14T11:26:24.5054138Z 70169C3A3AAAFE5A2282E01D5B9B7C99F2F4FF9B7514553434302674ABE46C98
12 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
13 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
14 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
15 ico\button_BJ.png button_BJ.png .png 1235 2024-12-13T07:41:34.0000000Z 414F0A23E917D41A82C697B0E9CDDDF9BFCACD1627310BD5717AEA172418AA51
16 ico\button_CAC.png button_CAC.png .png 1943 2024-12-13T07:41:34.0000000Z FA2B8877F8B41C70BA62C737C40374394D6840A53DFE2D0B0F83463C4817C138
17 ico\button_PXP-AAV.png button_PXP-AAV.png .png 1905 2024-12-13T07:41:34.0000000Z 6AB171963B553B434B5AA4BFBE91993B1782606AAF9B486FA23BB27BC1F0E4AA
18 ico\button_PXP-AC.png button_PXP-AC.png .png 1751 2024-12-13T07:41:34.0000000Z 60A6BD62EF3E50BDDCD00D9C281F2399E6C9D42E0C2DAA1742273C0DC48FE9C3
19 ico\button_PXP-AE.png button_PXP-AE.png .png 2001 2024-12-13T07:41:34.0000000Z F1DCF0E908F5AF38CCD13ABA58B824E5B186430414499C0F623C1D5EEAD8DAD7
20 ico\button_PXP-ALC.png button_PXP-ALC.png .png 1740 2024-12-13T07:41:34.0000000Z 19DB2449B66B95965155E6E3BC313BD52000FE38C95B0FAD90EF07DF53EA9CC9
21 ico\button_PXP-COV.png button_PXP-COV.png .png 2007 2024-12-13T07:41:34.0000000Z 529BDD045961C244FBD0DE177B899AF9E7CE0F4506B3463C56D2704B3BF207B5
22 ico\button_PXP-DAV.png button_PXP-DAV.png .png 1249 2025-01-28T06:29:03.3724433Z A7932F2ABDAC652EA3A901A221AB3BE2BC80FCB76FB203D44FC63AF3681F51E3
23 ico\button_PXP-DJ.png button_PXP-DJ.png .png 1470 2024-12-13T07:41:34.0000000Z 127957752DC2B6573CD2FD9A2791914BA22802E07E9B7E23CD3AE1FFF9E4FFC4
24 ico\button_PXP-ELN.png button_PXP-ELN.png .png 1927 2024-12-13T07:41:34.0000000Z DF32E6A465D5C196A17F3F96473315590DF2D71995F63D2D241D7599B62C99BB
25 ico\button_PXP-ESN.png button_PXP-ESN.png .png 1636 2024-12-13T07:41:34.0000000Z 0F089184CFD428DA3620F9C0AD2DCBFC21B31ED34FA301E8D5D45923783C0CC7
26 ico\button_PXP-IE.png button_PXP-IE.png .png 1885 2024-12-13T07:41:34.0000000Z F1C6373BFAF82E1908AF371D5F21133D27E0B84AFC706A703530855A2046FE43
27 ico\button_PXP-IOD.png button_PXP-IOD.png .png 1942 2024-12-13T07:41:34.0000000Z 494E90C899A0BB5BB365B0E1452E92A68359610C5A38A37D09F520A39BDB3899
28 ico\button_PXP-LN.png button_PXP-LN.png .png 2024 2024-12-13T07:41:34.0000000Z F3305ED6D266E29FA475D4C8A7DF38D2D2A1776A4CE379B876B6CFA278B2FE5D
29 ico\button_PXP-MA.png button_PXP-MA.png .png 1907 2024-12-13T07:41:34.0000000Z 9386249121644DCF4B9C70491F561E8B6E5BAAE87EBB2C76A84E46B10A60C8C3
30 ico\button_PXP-OA.png button_PXP-OA.png .png 1999 2024-12-13T07:41:34.0000000Z EAA2AD7EE5C6B7F3716AF1D7690C76AE59229AE9B6BA02009F5009CD9307F4E8
31 ico\button_PXP-OSI.png button_PXP-OSI.png .png 1508 2024-12-13T07:41:34.0000000Z 422D10B6EBDD76085CF9C5985391CCA060464B200D6792D604658AAE47F12CEE
32 ico\button_PXP-RA.png button_PXP-RA.png .png 1826 2024-12-13T07:41:34.0000000Z 1DB3F39C6CCA4EABE79D81DE6DCF9900649CDC9064F2EBF5E45F9AD849B8317F
33 ico\button_PXP-RMP.png button_PXP-RMP.png .png 1759 2024-12-13T07:41:34.0000000Z 156026EC6DEB7BDBE720047409E9A25615D650D8634C2F0BAF49A71E27135258
34 ico\button_PXP-ULN.png button_PXP-ULN.png .png 1928 2024-12-13T07:41:34.0000000Z B37EBB460691EAA7BC3A91DF05C27FE7CC1F0E9F9D69BDA7703956EAA7AA785C
35 ico\button_PXP-UOB.png button_PXP-UOB.png .png 1569 2024-12-13T07:41:34.0000000Z 3BC16B9923CCD34CF10F403CD65732B217E255E89539174841E7F30D9DFD2DCD
36 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
37 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
38 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
39 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
40 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
41 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
42 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
43 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
44 Settings.xml Settings.xml .xml 136576 2025-01-31T07:28:24.1891507Z AFEDC7526923ACE1DC60374F98F171809B36992262A37E1F11828F2B8D9799CB
45 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
46 Template.dwg Template.dwg .dwg 148438 2025-01-30T13:16:13.0000000Z EA279A30F113660CF53585451083BAEB0A01CA58C8F9A767C4B7C2A5E66ECCA3
47 test.txt test.txt .txt 0 2025-01-16T09:56:52.0597137Z E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855
48 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
49 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
50 Справка по работе с палитрой.docx Справка по работе с палитрой.docx .docx 855301 2025-02-05T09:53:08.1186958Z B6DC65BA17C4EA79DBCCC501909BEDBF56EB6A9F1E24BFAD1B01158B6EC639D0
51 Справка по работе с палитрой.pdf Справка по работе с палитрой.pdf .pdf 744035 2025-02-05T09:53:21.3752687Z 332CA70D3264C28BC712CA790157D0CD03BE7E07F42603DAF9247600271B7B75
52 Справка.docx Справка.docx .docx 87889 2025-02-06T05:13:36.4991443Z 14BA9BD252FB6DF37C4D151787E8799AB1703847C1DEC24F3EFE4D6805B0EB12
53 Справка.pdf Справка.pdf .pdf 154074 2025-02-06T05:14:24.2809131Z EB840514755A9F56ABC8351B8EC41233FE2512118DB2D4BA0ACD46E74D2C7DC9
@@ -0,0 +1,23 @@
"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 DeployedDll DeployedSha256 FileVersion AssemblyVersion LegacyProject LegacyCsproj LegacyOutputName TargetFramework PlatformTarget CommandCount RibbonRole ConfigurationSource MatchStatus
2 AnnotateLineNumbers.dll 799BC277ED98BD870F01824180B5A945B24F016DFD9956A8CCC630AFE80FDFA4 1.0.4.1 1.0.4.0 AnnotateLineNumbers AnnotateLineNumbers\AnnotateLineNumbers.csproj AnnotateLineNumbers v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
3 AutoAnnotateView.dll EAE55411EC84AC747A3816EE65CD095648282BE44235351E7C9ED1C188BCB611 1.0.4.6 1.0.4.0 AutoAnnotateView AutoAnnotateView\AutoAnnotateView.csproj AutoAnnotateView v4.7 x64 Settings.xml LIKELY_SOURCE_MATCH
4 AutoElevation.dll A8766B73B30F8F44213769EB7824F82F73ED418DE0EF49695E00A3EB7FC28885 1.0.4.5 1.0.4.0 AutoElevation AutoElevation\AutoElevation.csproj AutoElevation v4.7 x64 LIKELY_SOURCE_MATCH
5 AutoLinenumberConnections.dll 0F6B2A759F2A63B9152B480B6968BDA20F42AD1D922F67CA6E6BFD31A0715F35 1.0.4.1 1.0.4.0 AutoLinenumberConnections AutoLinenumberConnections\AutoLinenumberConnections.csproj AutoLinenumberConnections v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
6 CheckOrthoView.dll 89DB5E537C2BAEC292CDD8A0A43819119850119D54958E8CC3C43E2136AB24A0 1.0.4.1 1.0.4.0 CheckOrthoView CheckOrthoView\CheckOrthoView.csproj CheckOrthoView v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
7 EnablerLoader.dll 745D625C27F92842F2EFF3C515DB48BB9FC0FE59B6E4559ACD696BCC93FF4C51 1.0.4.1 1.0.4.0 EnablerLoader EnablerLoader\EnablerLoader.csproj EnablerLoader v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
8 ExtentsIntersect.dll 59D2776E083D4BE2FBFBE4F43009EAA9CE0E81A1752815B45C510F80654169D9 1.0.4.1 1.0.4.0 ExtentsIntersect ExtentsIntersect\ExtentsIntersect.csproj ExtentsIntersect v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
9 GetComponentGeometry.dll 9EA8C48FEC4D36D32CC6AC88B78C434662C16D4484F86EFAA46ECC309E9F9142 1.0.4.1 1.0.4.0 GetComponentGeometry GetComponentGeometry\GetComponentGeometry.csproj GetComponentGeometry v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
10 GetLinenumberAnnotationText.dll 047E721DBE380061D9ABDD92BC5475440E3B7ADB2BD0F544D538BA24B121D30E 1.0.4.1 1.0.4.0 GetLinenumberAnnotationText GetLinenumberAnnotationText\GetLinenumberAnnotationText.csproj GetLinenumberAnnotationText v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
11 GetPlantScriptFolderPath.dll 5080A167A1DAF6C19BE08F6CAB678D490736B5E07E121A918A2915F0D9D8BF65 1.0.4.1 1.0.4.0 GetPlantScriptFolderPath GetPlantScriptFolderPath\GetPlantScriptFolderPath.csproj GetPlantScriptFolderPath v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
12 GetScriptParameters.dll 7FF7B0D26BE8D89FF4A5A4FA1C6EC17BDA1506BCCB78AFE0E650137CD7BA5E87 1.0.4.1 1.0.4.0 GetScriptParameters GetScriptParameters\GetScriptParameters.csproj GetScriptParameters v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
13 InsertElevation.dll B2D8B98F353D3DDF3A75A848E6922DC30478A1E5F98B372F24A049690492CA82 1.0.4.5 1.0.4.0 InsertElevation InsertElevation\InsertElevation.csproj InsertElevation v4.7 x64 Settings.xml MULTIPLE_CANDIDATES
14 LoadedAssemblies.dll 277EB83C587A984DC2F558B8A7C86AA98E195A77AFA6FADD7DB83F2B801C0E4E 1.0.4.1 1.0.4.0 LoadedAssemblies LoadedAssemblies\LoadedAssemblies.csproj LoadedAssemblies v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
15 OrthoAutoAnnotate.dll 2E6BB2EBA0A98AA3550DFA4E661832FC7C3EE3DFF49BFC147BD4DFAD7A8596C6 1.0.4.6 1.0.4.0 OrthoAutoAnnotate OrthoAutoAnnotate\OrthoAutoAnnotate.csproj OrthoAutoAnnotate v4.7 x64 Settings.xml LIKELY_SOURCE_MATCH
16 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
17 P3DCopyMissingObjectsToOrthoDrawing.dll 6C879AFC7F35EC25E5E2FFF9FF0EE6EFEB155670DBFD5D0CDE9A0F629C5727B2 1.0.4.1 1.0.4.0 P3DCopyMissingObjectsToOrthoDrawing P3DCopyMissingObjectsToOrthoDrawing\P3DCopyMissingObjectsToOrthoDrawing.csproj P3DCopyMissingObjectsToOrthoDrawing v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
18 P3DNewMa.dll 97246EE9B24FD373551EF380C5AC0CA78CF7ED6A92581D5331B54445A2A948F8 1.0.4.7 1.0.4.0 P3DNewMa P3DNewMa\P3DNewMa.csproj P3DNewMa v4.7 x64 Settings.xml LIKELY_SOURCE_MATCH
19 P3DSetLayerForViewport.dll A28E58FDE4EBAA26450CA646C5A85F98C1A04AA42C0BFC5413D718243ACBF42B 1.0.4.1 1.0.4.0 P3DSetLayerForViewport P3DSetLayerForViewport\P3DSetLayerForViewport.csproj P3DSetLayerForViewport v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
20 PHPAnnotations.dll 816A693F1B775C5506043DD0461055F248D3897E96AC8285DAE29C5B71FFCA73 1.0.4.1 1.0.4.0 PHPAnnotations PHPAnnotations\PHPAnnotations.csproj PHPAnnotations v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
21 SwapAttributesMenuItem.dll 674C865E34F9C877561E03E2077F7F2C6852C3C8B6A0503ACAA7619F3E80A460 1.0.4.1 1.0.4.0 SwapAttributesMenuItem SwapAttributesMenuItem\SwapAttributesMenuItem.csproj SwapAttributesMenuItem v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
22 UpdateOrthoDrawings.dll 1492FFF1F56853DF4A858CD3E926640C1E4920B5E3F5CFCC12F4389855E630BA 1.0.4.2 1.0.4.0 UpdateOrthoDrawings UpdateOrthoDrawings\UpdateOrthoDrawings.csproj UpdateOrthoDrawings v4.7 x64 Settings.xml LIKELY_SOURCE_MATCH
23 UpdateOrthoViewBlocks.dll 5022D4D66D79E7E7D82F9FCB647DF8E1FE658A4694EB7794E363D5EA5517F5A9 1.0.4.1 1.0.4.0 UpdateOrthoViewBlocks UpdateOrthoViewBlocks\UpdateOrthoViewBlocks.csproj UpdateOrthoViewBlocks v4.7 x64 Settings.xml EXACT_SOURCE_MATCH
@@ -0,0 +1,4 @@
GeneratedUtc=2026-07-31T10:39:04.9899963Z
DeployedRoot=<network root redacted to report context>
SourceRoot=<network source root redacted to report context>
Files=52
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,29 @@
# 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.
@@ -0,0 +1,10 @@
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 Dependency Category Evidence Requiredness Notes
2 Settings.xml CONFIGURATION OrthoLoader/Loader.cs:104-112 Required Adjacent to each loaded assembly directory
3 ico/*.png RIBBON_RESOURCE OrthoLoader/Ribbon.cs:330-357 Optional Missing file yields null image
4 app_netload.lsp CONFIGURATION OrthoLoader/Ribbon.cs:380-405 Implicit Local workstation file gates project-specific Ribbon enablement
5 annotation_settings.xml REFERENCE_DATA README.md and annotation modules Project-dependent Located in Plant project data area according to README
6 users_indexes_list.xml REFERENCE_DATA README.md and annotation modules Project-dependent Located in Plant project data area according to README
7 Template.dwg TEMPLATE Deployed inventory and legacy source references Command-dependent Do not treat deployed copy as approved test fixture
8 Plant project XML/DWG COMMAND_TIME UpdateOrthoDrawings and annotation source Required by relevant command Out of deployed plugin root
9 Update report CSV LOGGING UpdateOrthoDrawings/Commands.cs:1004 Optional Written beside executing assembly by legacy command
10 Help PDFs RIBBON_RESOURCE P3DNewMa/NewMaControl.cs:6214 Optional Opened from assembly directory
@@ -0,0 +1,9 @@
# 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.
@@ -0,0 +1,57 @@
# Аудит P_Rec_Annot v1.0.4
Статический read-only аудит каталога `C:\dev\autodesk\link\P_Rec_Annot\v1.0.4\repo`. Исходник не запускался, не собирался и не изменялся. `link` — symbolic link на `\\phpsrv03\NBIM\code\CS`; каталог `repo` внутри ссылки — обычный directory без собственного `.git`.
## Краткий вывод
P_Rec_Annot — не одна команда, а комплект из 25 legacy-проектов DLL в одном решении. Его ядро обслуживает орто-чертежи Plant 3D: пакетное обновление, размещение и обновление line-number annotations, автоматические осевые линии/опоры, высотные отметки, проверку отсутствующих объектов, слои viewport и ручные annotation blocks. README исходника прямо называет former `OrthoTools.2019` (`README.md:1-5`) и перечисляет пользовательские команды (`README.md:8-31`).
Наиболее употребляемым в агрегате XLSX является имя `P_Rec_Annot` с 218 796 единицами `Количество запусков`. В самом исходном комплекте не найден подтверждённый writer этого счётчика: найдено только использование `Environment.MachineName` для выбора пользовательского индекса (`GetLinenumberAnnotationText/LinenumberAnnotation.cs:225-250`). Поэтому 218 796 нельзя доказательно трактовать как число ручных запусков; это внешний агрегат, семантику которого нужно подтвердить по источнику telemetry.
## Факты инвентаризации
| Показатель | Значение |
|---|---:|
| Файлов | 271 |
| C# исходников вне bin/obj/lib | 120 |
| Проектов `.csproj` | 25 |
| DLL в исходном комплекте | 32 |
| AutoCAD-команд, включая архивные совпадения | 49 |
| Ribbon/even-handler/database/write evidence строк | 4 402+ |
| Target framework актуальных проектов | `.NET Framework 4.7` |
| Platform target | `x64` |
| Решение | `P_Rec_Annot.sln` |
### Подтверждённые точки входа
* `OrthoLoader` реализует загрузку библиотек: читает `Settings.xml`, строит путь от расположения текущей DLL и вызывает `Editor.Command("._netload", ...)` для каждой сборки (`OrthoLoader/Loader.cs:24-29`, `48-82`, `105-118`).
* `Ribbon` реализует `IExtensionApplication`, подписывается на `ComponentManager.ItemInitialized`, создаёт вкладку `PluginsOrtho`, восемь панелей и кнопки с командами (`OrthoLoader/Ribbon.cs:16-29`, `151-190`, `193-377`).
* Кнопка аннотаций содержит `PXP-AAV`, `MTO-MA` и `BJ`; отдельные команды актуальны для line numbers, elevations, supports, layers и package update (`OrthoLoader/Ribbon.cs:31-145`).
* `PXP-IOD` — session command пакетного обновления (`UpdateOrthoDrawings/Commands.cs:519`); `PXP-OA` и `PXP-AAV` запускают автоматическую обработку, но автоматического event-trigger для самих аннотаций не доказано (`OrthoAutoAnnotate/Commands.cs:6444-6463`, `AutoAnnotateView/Commands.cs:546`).
## Назначение и миграция
Основной переносимый контракт — правила получения Plant 3D данных, построения текста и создания/обновления орто-аннотаций. Переносить следует после выделения Core и Plant 3D 2026 adapter. Не переносить как есть загрузчик `_netload`, hard-coded ссылки на AutoCAD 2019, архивные копии и не подтверждённую telemetry.
Официальная документация Plant 3D 2026 подтверждает, что штатные annotations предназначены для orthographic drawings и поддерживают annotation styles/templates: [About Annotations](https://help.autodesk.com/cloudhelp/2026/ENU/Plant3D-UserGuide/files/GUID-DAD70DC1-32F2-45E4-8685-0C574EF31299.htm). Штатная настройка формата выполняется через Project Setup/Class Definitions: [Annotation Format for Orthographic Drawings](https://help.autodesk.com/cloudhelp/2026/ENU/Plant3D-UserGuide/files/GUID-6D5E3975-53AA-4495-B727-BBB2B599B464.htm). Это подтверждает область пересечения, но не доказывает замену корпоративных правил P_Rec_Annot. Autodesk также документирует создание и обновление Ortho, но не эквивалент всего пакетного поведения: [Create an Orthographic View](https://help.autodesk.com/cloudhelp/2026/ENU/Plant3D-UserGuide/files/GUID-09E67A9E-45F7-4392-BFB6-EF1ADAB1D46B.htm), [About Updating Orthographic Drawings](https://help.autodesk.com/cloudhelp/2026/ENU/Plant3D-UserGuide/files/GUID-EEDF2AA7-B50B-484B-B47F-B48986A51C3D.htm).
**Нужна ли новая кнопка:** да, на первом этапе миграции нужна одна явная корпоративная кнопка/команда для воспроизводимого сценария `Annotate`, а не набор скрытых вызовов. Предварительно: вкладка «Промхимпроект», панель «Аннотации», команда `PHP_REC_ANNOT`; состав операций должен выбираться режимом или отдельными безопасными командами после golden-теста.
## Ответы на ключевые вопросы
* **Автоматические вызовы:** обнаружены загрузочные и UI-события Ribbon, WinForms-события и временные PointMonitor; подписок `CommandWillStart/ObjectModified/Idle`, запускающих аннотацию, не подтверждено. Риск повторной подписки остаётся у `CurrentProjectOpenedEvent`.
* **DWG:** код массово использует AutoCAD transactions, `OpenMode.ForWrite`, `AppendEntity`, `BlockReference`, `MLeader`, attributes и удаление старых annotation blocks; изменения проекта через Plant DataLinks читаются, прямой SQL/SQLite writer в исходниках не найден.
* **DCF/Project.xml:** прямое изменение не доказано; `PnPDatabase`/`DataLinksManager` и `PnPDrawings/PnPDatalinks` создают косвенный риск через Plant API.
* **Повторный запуск:** есть явные удаления ранее размещённых blocks в `AutoAnnotateView/Commands.cs:476-500` и `AutoLinenumberConnections/Commands.cs:535-558`, но глобальная идемпотентность всего комплекта не доказана.
* **Undo:** операции заключены в AutoCAD transactions; гарантия единого undo для пакетного сценария не доказана, нужна runtime-проверка.
* **Build:** не выполнялся по ограничению задания.
## Файлы отчёта
Основные выводы находятся в Markdown-файлах этого каталога. `evidence/` содержит только агрегированные CSV/текстовые метаданные, без копий исходников и бинарников. Инструмент повторного read-only анализа: `tools/plugin-usage/Build-PRecAnnotAudit.ps1`.
## Ограничения
1. Исходный каталог содержит release DLL, DWG, DOCX, PDF и ZIP, но они не добавляются в Git и не открывались/запускались.
2. Без Plant 3D 2019 runtime нельзя подтвердить фактические визуальные результаты, число созданных объектов, undo и семантику внешнего usage-счётчика.
3. Сравнение 2026 ограничено официально опубликованными возможностями; полноценная замена требует runtime-сопоставления корпоративных annotation blocks и правил.
@@ -0,0 +1,7 @@
# Annotation algorithm
По исходникам функция `Annot` — создание/обновление орто-аннотаций и вспомогательных graphic blocks, а не универсальное 3D/P&ID/Iso annotator. Plant 3D 2026 официально также ограничивает понятие annotations orthographic drawings ([About Annotations](https://help.autodesk.com/cloudhelp/2026/ENU/Plant3D-UserGuide/files/GUID-DAD70DC1-32F2-45E4-8685-0C574EF31299.htm)); поэтому область совпадает, но корпоративная логика не считается заменённой.
Подтверждённые операции: viewport/block selection, поиск DataLinks/PnP rows, чтение line groups и connected parts, transaction writes, block/attribute/MLeader/dimension operations. `AutoAnnotateView/Commands.cs:476-500` сначала удаляет ранее размещённые annotations, затем вызывает `InsertAnnotations()` и пишет count; `AutoLinenumberConnections/Commands.cs:535-558` аналогично удаляет blocks стыков. Это признаки частичной идемпотентности, но не доказательство для всего набора.
Нужно сохранять в golden test: тип блока, attribute tags/text, layer, scale, rotation, coordinates, XData/DataLinks, number of created/updated/skipped entities. Сырые DWG не включены.

Some files were not shown because too many files have changed in this diff Show More