Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36178374db | ||
|
|
b33351117b | ||
|
|
56dcce42f6 | ||
|
|
ea3a9e157e | ||
|
|
837ac1d994 | ||
|
|
142b5f8b93 | ||
|
|
dcf95d7e3a | ||
|
|
c733a27cbb | ||
|
|
9a7a6cd678 | ||
|
|
66e52e5f50 | ||
|
|
60b3934cd2 | ||
|
|
34222cedc8 | ||
|
|
596c95a4b6 | ||
|
|
6f29a5bce4 | ||
|
|
871499fa03 | ||
|
|
bbe87611bf | ||
|
|
25e1def353 | ||
|
|
f7fa0363e3 | ||
|
|
11e6e5d806 | ||
|
|
77a62d4564 | ||
|
|
dab90f0780 | ||
|
|
3f2fbe9883 | ||
|
|
86fb8c89d2 | ||
|
|
958bad6823 | ||
|
|
1bda2c155d | ||
|
|
287ddff438 |
+11
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
+25
@@ -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>
|
||||||
+72
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
@@ -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,11 @@
|
|||||||
|
# Launcher source architecture audit
|
||||||
|
|
||||||
|
Static, read-only audit of three independent nested Git repositories under `C:\dev\launcher\git` and the linked bootstrap repository `C:\dev\p_serv_starter`. No launcher executable, helper, build, installer, registry action, profile change, or AutoCAD process was run.
|
||||||
|
|
||||||
|
The three projects are all independent WinForms `WinExe` applications, not a three-project solution: `All_Serv_Install`, `All_Serv_InstallUpdater`, and `All_Serv_Load`. Each solution contains exactly one project and has Debug/Release AnyCPU `Build.0` enabled. There are no project references between them.
|
||||||
|
|
||||||
|
The current primary installer is `All_Serv_Install.exe` (v1.1.0.0). It presents a Form1 UI, reads server-side settings, copies selected corporate application directories to local targets, then starts a configured external executable. `All_Serv_InstallUpdater.exe` is a visible but short-lived self-updater: it updates the local installer with MD5 checks and starts `All_Serv_Install.exe`. `All_Serv_Load.exe` is an earlier separate WinForms loader (v1.0.0.0); source evidence does not establish that it is part of the current primary workflow.
|
||||||
|
|
||||||
|
No static source evidence shows a direct reference to `app_netload.lsp`, `OrthoLoader.dll`, or `P_Rec_Annot` in the updater. The installer configuration does establish `P_Rec_Annot` deployment: catalog `P_Rec_Annot`, install file `OrthoLoader.dll`, source DriveA, target `C:\AutoCAD User Support\Applications`.
|
||||||
|
|
||||||
|
`P_Serv_Starter.exe` is the confirmed external bootstrap selected by the Plant 3D installer pattern. Its source creates `_app_netload.lsp` from the installer `Settings.xml`, copies `launcher_starter.lsp` to the local Applications directory, and launches the configured local `start_plant_3d.bat`. The LISP renames the generated file to `app_netload.lsp`, adds the local Applications directory to SupportPath, and loads it. The source comment attributes LISP auto-start to `acaddoc.lsp`, but that file and the expected local installation were absent on this workstation during the audit; automatic startup remains `UNKNOWN`.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Application synchronization analysis
|
||||||
|
|
||||||
|
`All_Serv_Install` is a synchronizing installer, not merely a launcher. It reads server `Settings.xml`, selects products/plugins, and recursively copies application directories from configured network roots to local targets. Its copy path includes directory deletion and file deletion (`Form1.cs:396-645`), so it was not executed for this audit.
|
||||||
|
|
||||||
|
For Plant 3D_2019, the configuration specifies DriveA and DriveQ network sources and local target `C:\AutoCAD User Support\Applications`. The catalog `P_Rec_Annot` is selected with install entry `OrthoLoader.dll`; `download_list_plant.xml` maps the P_Rec_Annot plugin ID to a real area. This proves the intended local deployment source and target, but not the state of a particular workstation installation.
|
||||||
|
|
||||||
|
`All_Serv_InstallUpdater` is a separate local updater. Its `Form1_Load` computes MD5 checksums, compares a server control-sums manifest, deletes stale local files, copies changed files, writes a local control-sums file and starts `All_Serv_Install.exe`. It references `N:\BIM\save\All_Serv_Install` and `N:\BIM\data_write\All_Serv_Install`; it can write both local and server test/control files. It was not executed.
|
||||||
|
|
||||||
|
| Component | Source | Target | Version check | Destructive operations | Role | Confidence |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| All_Serv_Install | configured network DriveA/DriveQ | configured local target | configuration-driven selection; no content hash established | recursive copy, delete files/directories | application synchronizer | CONFIRMED |
|
||||||
|
| All_Serv_InstallUpdater | server installer directories | updater own local directory | MD5 control sums | deletes stale local files; writes control sums | installer updater | CONFIRMED |
|
||||||
|
| P_Serv_Starter | local installed executable + installer Settings.xml | local Applications | none | overwrites `_app_netload.lsp` and `launcher_starter.lsp` | per-launch bootstrap | CONFIRMED |
|
||||||
|
|
||||||
|
Offline behavior is `UNKNOWN`: both installer and updater require configured paths at the point they run, but source evidence does not show an explicit offline fallback.
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Architecture summary
|
||||||
|
|
||||||
|
## Project architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
All_Serv_InstallUpdater.exe (independent repository / updater)
|
||||||
|
-> starts local All_Serv_Install.exe
|
||||||
|
|
||||||
|
All_Serv_Install.exe (independent repository / visible installer and synchronizer)
|
||||||
|
-> starts configured P_Serv_Starter.exe
|
||||||
|
|
||||||
|
P_Serv_Starter.exe (separate repository / Plant bootstrap)
|
||||||
|
-> generates local _app_netload.lsp
|
||||||
|
-> copies launcher_starter.lsp
|
||||||
|
-> starts configured local start_plant_3d.bat
|
||||||
|
|
||||||
|
All_Serv_Load.exe (independent repository)
|
||||||
|
-> separate historical/alternate loader; no source dependency edge to the above
|
||||||
|
```
|
||||||
|
|
||||||
|
There are no ProjectReference edges between the three projects beneath `C:\dev\launcher\git`; each is its own one-project solution. `P_Serv_Starter` is a fourth independent repository selected by configuration, not a project reference. This explains why a user can experience one product despite multiple executables: the updater can hand off to the visible installer, which synchronizes selected applications and delegates Plant startup to a hidden bootstrap. Actual deployed shortcut/entrypoint evidence was not present, so the updater's use in a specific installation remains `INFERRED`.
|
||||||
|
|
||||||
|
## Roles and status
|
||||||
|
|
||||||
|
| Project | Output | Role | Status | Evidence |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| All_Serv_Install | WinExe | visible catalog UI, synchronizer and configured process launcher | ACTIVE_PRIMARY | Form1 startup and copy/Process.Start code |
|
||||||
|
| All_Serv_InstallUpdater | WinExe | installer self-updater then handoff | ACTIVE_UPDATER | Form1_Load MD5/copy/start code |
|
||||||
|
| All_Serv_Load | WinExe | independent alternate/historical loader | UNKNOWN | independent Form1 and 2019 batch script; no current deployment proof |
|
||||||
|
| P_Serv_Starter | Exe | Plant session bootstrap | ACTIVE_BOOTSTRAP | selected in installer XML; writes loader and starts batch |
|
||||||
|
|
||||||
|
## Runtime chain
|
||||||
|
|
||||||
|
The confirmed chain ends at execution of the local Plant start batch. The gap between that batch and `launcher_starter.lsp` automatic loading is `UNKNOWN`: no local batch, `acaddoc.lsp`, or Applications files were found. Once `launcher_starter.lsp` runs, all following load steps are source-confirmed.
|
||||||
|
|
||||||
|
## Answers to the requested questions
|
||||||
|
|
||||||
|
1. The three launcher projects are independent WinForms executables: installer, updater and alternate loader.
|
||||||
|
2. `All_Serv_Install` creates the confirmed visible catalog UI.
|
||||||
|
3. Three EXE artifacts are declared by the launcher repositories; P_Serv_Starter adds one external bootstrap EXE.
|
||||||
|
4. No launcher project is a class library; `launcher_starter.lsp` is a copied runtime content artifact.
|
||||||
|
5. Updater/helper/bootstrap behavior is confirmed in source; actual installed-process count is unknown.
|
||||||
|
6. Plant launch is delegated by `All_Serv_Install` to configured `P_Serv_Starter.exe`; the bootstrap starts a local batch. A current Plant executable/profile/argument string is not proven.
|
||||||
|
7. `app_netload.lsp` and `OrthoLoader.dll` are connected through P_Serv_Starter generated LISP and installer plugin metadata.
|
||||||
|
8. The installer synchronizes corporate application folders to a local Applications cache; it does not load P_Rec_Annot directly.
|
||||||
|
9. No source proof warrants modifying LAUNCHER for the new plugin yet. First validate one local entry assembly through the established catalog/bootstrap contract in a non-production pilot.
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# AutoCAD/Plant launch chain
|
||||||
|
|
||||||
|
## Confirmed static chain
|
||||||
|
|
||||||
|
```text
|
||||||
|
User
|
||||||
|
-> All_Serv_Install.exe Form1
|
||||||
|
-> copy selected Plant 3D application folders to local Applications target
|
||||||
|
-> Process.Start(P_Serv_Starter.exe, five configured arguments)
|
||||||
|
-> P_Serv_Starter creates local _app_netload.lsp and copies launcher_starter.lsp
|
||||||
|
-> P_Serv_Starter starts C:\AutoCAD User Support\p3d-support\start_plant_3d.bat
|
||||||
|
-> [UNKNOWN: installed batch contents and auto-load registration]
|
||||||
|
-> Plant 3D startup
|
||||||
|
-> launcher_starter.lsp (when loaded)
|
||||||
|
-> local app_netload.lsp
|
||||||
|
-> _netload local Applications\P_Rec_Annot\OrthoLoader.dll
|
||||||
|
-> OrthoLoader Settings.xml -> sequential ._netload configured modules
|
||||||
|
```
|
||||||
|
|
||||||
|
The final two steps are confirmed by the prior P_Rec_Annot legacy audit. The activation of `launcher_starter.lsp` is not directly implemented in `P_Serv_Starter`: `Program.cs:181` comments that `acaddoc.lsp` renames the generated file, while the copied LISP itself performs the rename and `load`. No matching installed `acaddoc.lsp`, batch file, or Applications directory existed on this workstation at audit time. Therefore automatic execution is `UNKNOWN`, not asserted.
|
||||||
|
|
||||||
|
## Source evidence
|
||||||
|
|
||||||
|
| Claim | Evidence | Confidence |
|
||||||
|
|---|---|---|
|
||||||
|
| User-visible installer starts configured object | `All_Serv_Install/Form1.cs:195-230` | CONFIRMED |
|
||||||
|
| Plant pattern object is `P_Serv_Starter.exe` | `All_Serv_Install/Settings.xml`, Plant 3D pattern | CONFIRMED |
|
||||||
|
| Five arguments include product, Applications target, settings, project and working directory | `Settings.xml` Plant pattern and `P_Serv_Starter/Program.cs:12-23` | CONFIRMED |
|
||||||
|
| Bootstrap generates `_app_netload.lsp` | `P_Serv_Starter/Program.cs:58-182` | CONFIRMED |
|
||||||
|
| Generated DLL modules use AutoLISP `_netload` | `P_Serv_Starter/Program.cs:146-151` | CONFIRMED |
|
||||||
|
| Bootstrap copies LISP and starts local batch file | `P_Serv_Starter/Program.cs:202-216,49-54` | CONFIRMED |
|
||||||
|
| Generated LISP is renamed, SupportPath updated and loaded | `P_Serv_Starter/launcher_starter.lsp:74-96` | CONFIRMED |
|
||||||
|
| Actual installed batch launches a specific Plant version | no installed batch was present | UNKNOWN |
|
||||||
|
|
||||||
|
## Historical source-only alternative
|
||||||
|
|
||||||
|
`All_Serv_Load/Addins/start_plant_3d.bat` contains a direct AutoCAD 2019 command using `/product PLNT3D /language "ru-RU"`, preceded by PlantCleaner and PnPPersistentCache cleanup. It is evidence only of that project's historical script; it is not evidence that current users start Plant 3D 2026 this way. No `/ld`, profile, LISP or script argument is present in that script.
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Artifact,SourceProject,Type,AssemblyName,AssemblyVersion,TargetFramework,Architecture,ReferencedBy,VisibleToUser,RequiredAtRuntime,LikelyRole,Confidence
|
||||||
|
All_Serv_Install.exe,All_Serv_Install,WinExe,All_Serv_Install,1.1.0.0,.NETFramework v4.7,AnyCPU,None,Yes,Yes,PRIMARY_INSTALLER,CONFIRMED
|
||||||
|
All_Serv_InstallUpdater.exe,All_Serv_InstallUpdater,WinExe,All_Serv_InstallUpdater,3.0.0.0,.NETFramework v4.7,AnyCPU,None,Briefly,Only when updater is launched,UPDATER_HELPER,CONFIRMED
|
||||||
|
All_Serv_Load.exe,All_Serv_Load,WinExe,All_Serv_Load,1.0.0.0,.NETFramework v4.8,AnyCPU,None,Possible,Unknown,SEPARATE_LEGACY_LOADER,INFERRED
|
||||||
|
P_Serv_Starter.exe,P_Serv_Starter,Exe,P_Serv_Starter,3.0.1.0,.NETFramework v4.7,AnyCPU,Configured external process from All_Serv_Install,No,Yes for Plant launch bootstrap,BOOTSTRAP,CONFIRMED
|
||||||
|
launcher_starter.lsp,P_Serv_Starter,Content,LISP script,N/A,N/A,N/A,Copied by P_Serv_Starter,No,Yes for generated loader activation,LISP_BOOTSTRAP,CONFIRMED
|
||||||
|
|
||||||
|
@@ -0,0 +1,7 @@
|
|||||||
|
Solution,Configuration,Platform,Project,BuildEnabled,Notes
|
||||||
|
All_Serv_Install.sln,Debug,Any CPU,All_Serv_Install,yes,Only project in solution
|
||||||
|
All_Serv_Install.sln,Release,Any CPU,All_Serv_Install,yes,Only project in solution
|
||||||
|
All_Serv_InstallUpdater.sln,Debug,Any CPU,All_Serv_InstallUpdater,yes,Only project in solution
|
||||||
|
All_Serv_InstallUpdater.sln,Release,Any CPU,All_Serv_InstallUpdater,yes,Only project in solution
|
||||||
|
All_Serv_Load.sln,Debug,Any CPU,All_Serv_Load,yes,Only project in solution
|
||||||
|
All_Serv_Load.sln,Release,Any CPU,All_Serv_Load,yes,Only project in solution
|
||||||
|
@@ -0,0 +1,10 @@
|
|||||||
|
Setting,OwnerProject,Source,DefaultValue,RuntimeValueSource,Purpose,Sensitive,Required,Fallback,UsedBy,Confidence
|
||||||
|
InstallerSettings,All_Serv_Install,N:\BIM\save\All_Serv_Install\Settings.xml,None,hard-coded source path,product/plugin catalog and process pattern,No,Yes,None,Form1,CONFIRMED
|
||||||
|
PlantApplicationTarget,All_Serv_Install,Settings.xml Plant pattern,C:\AutoCAD User Support\Applications,XML target,local corporate applications directory,No,Yes,None,copy and P_Serv_Starter arguments,CONFIRMED
|
||||||
|
PlantBootstrapExecutable,All_Serv_Install,Settings.xml Plant pattern,C:\CAD User Support\All_Serv_Install\P_Serv_Starter.exe,XML object,external bootstrap process,No,Yes,None,Form1 Process.Start,CONFIRMED
|
||||||
|
PluginCatalog,All_Serv_Install,Settings.xml plugin entry,P_Rec_Annot,XML plugin metadata,subdirectory to deploy,No,Optional by selection,skip if unselected,copy and generated loader,CONFIRMED
|
||||||
|
PluginInstall,All_Serv_Install,Settings.xml plugin entry,OrthoLoader.dll,XML plugin metadata,first P_Rec_Annot assembly to load,No,Optional by selection,skip if catalog unavailable,P_Serv_Starter generated LISP,CONFIRMED
|
||||||
|
GeneratedLoader,P_Serv_Starter,arguments plus installer Settings.xml,_app_netload.lsp,computed at runtime,per-session module loading commands,Contains project path in first comment,Yes,abort on error,generated LISP,CONFIRMED
|
||||||
|
PlantStartScript,P_Serv_Starter,Program.cs,C:\AutoCAD User Support\p3d-support\start_plant_3d.bat,hard-coded source constant,launch Plant process,No,Yes,none,Process.Start,CONFIRMED
|
||||||
|
UpdaterServer,All_Serv_InstallUpdater,Form1.cs,N:\BIM\data_write\All_Serv_Install,hard-coded source constant,hash/copy source,No,Yes,none,Form1_Load,CONFIRMED
|
||||||
|
|
||||||
|
@@ -0,0 +1,7 @@
|
|||||||
|
Artifact,ExpectedInstalledLocation,SourceProject,SourceAssemblyVersion,InstalledBinaryFound,InstalledMetadataMatch,MatchStatus,Notes
|
||||||
|
All_Serv_Install.exe,C:\CAD User Support\All_Serv_Install\All_Serv_Install.exe,All_Serv_Install,1.1.0.0,False,N/A,NO_DEPLOYED_BINARY_FOUND,Expected location came from Settings.xml object directory
|
||||||
|
P_Serv_Starter.exe,C:\CAD User Support\All_Serv_Install\P_Serv_Starter.exe,P_Serv_Starter,3.0.1.0,False,N/A,NO_DEPLOYED_BINARY_FOUND,Expected location came from Settings.xml object
|
||||||
|
All_Serv_InstallUpdater.exe,UNKNOWN,All_Serv_InstallUpdater,3.0.0.0,False,N/A,NO_DEPLOYED_BINARY_FOUND,No installed location in inspected source
|
||||||
|
All_Serv_Load.exe,UNKNOWN,All_Serv_Load,1.0.0.0,False,N/A,NO_DEPLOYED_BINARY_FOUND,No installed location in inspected source
|
||||||
|
start_plant_3d.bat,C:\AutoCAD User Support\p3d-support\start_plant_3d.bat,P_Serv_Starter dependency,N/A,False,N/A,NO_DEPLOYED_FILE_FOUND,Not present at expected path during audit
|
||||||
|
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
EntryPoint,Project,File,Lines,ApplicationType,CommandLineArguments,CreatesVisibleWindow,CanRunHeadless,StartsOtherProcesses,SingleInstanceMechanism,ExitBehavior,Confidence
|
||||||
|
Program.Main,All_Serv_Install,All_Serv_Install/Program.cs,13-18,WinForms,none parsed,yes Form1,no,yes configured object,none,Application exits after launching configured object,CONFIRMED
|
||||||
|
Program.Main,All_Serv_InstallUpdater,All_Serv_InstallUpdater/Program.cs,13-18,WinForms,none parsed,yes Form1,no,yes All_Serv_Install.exe,none,exits after updater starts installer,CONFIRMED
|
||||||
|
Program.Main,All_Serv_Load,All_Serv_Load/All_Serv_Load/Program.cs,12-17,WinForms,none parsed,yes Form1,no,yes configured object,none,Application exits after launching configured object,CONFIRMED
|
||||||
|
@@ -0,0 +1,11 @@
|
|||||||
|
Generated: 2026-07-31
|
||||||
|
Method: static read-only source and metadata inspection; no build or executable launch.
|
||||||
|
|
||||||
|
Launcher sources:
|
||||||
|
- C:\dev\launcher\git\All_Serv_Install @ 29d163322a2dc01983e15bc26f62f787fb402a04 (main; clean)
|
||||||
|
- C:\dev\launcher\git\All_Serv_InstallUpdater @ 1e6f2e78a1ca0005ee704dbeb464e73a478c3390 (main; clean)
|
||||||
|
- C:\dev\launcher\git\All_Serv_Load @ bd38b87f903b02533dbb659d9776cd32dd9e2a3e (main; clean)
|
||||||
|
- C:\dev\p_serv_starter @ 8219520aad1a5ccc2cd96611f2a71fef61cbf9b1 (main; clean)
|
||||||
|
|
||||||
|
Local deployment probes were metadata-only. Expected launcher/bootstrap files and the relevant AutoCAD User Support scripts were absent at the configured locations at audit time.
|
||||||
|
No source, binaries, user configuration content, registry export, DWG, DCF, Project.xml or network files are included in this audit.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# LAUNCHER to P_Rec_Annot
|
||||||
|
|
||||||
|
## Proven loading contract
|
||||||
|
|
||||||
|
| Stage | Evidence | Finding | Confidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Deployment selection | `All_Serv_Install/Settings.xml`; `download_lists/download_list_plant.xml` | `P_Rec_Annot` is an installer plugin catalog; its configured install file is `OrthoLoader.dll`. | CONFIRMED |
|
||||||
|
| Local target | Plant configuration target | P_Rec_Annot is intended to be copied under `C:\AutoCAD User Support\Applications\P_Rec_Annot`. | CONFIRMED |
|
||||||
|
| Bootstrap selection | installer Plant pattern object/arguments | `All_Serv_Install` invokes `P_Serv_Starter.exe` after install selection. | CONFIRMED |
|
||||||
|
| Generated first load | `P_Serv_Starter/Program.cs:140-174` | For each installed DLL catalog, generated LISP emits `(command "_netload" attribute)`. Therefore `OrthoLoader.dll` is loaded from the local Applications path when its catalog exists. | CONFIRMED |
|
||||||
|
| Generated LISP activation | `launcher_starter.lsp:74-96` | It renames `_app_netload.lsp`, adds Applications to SupportPath and `(load ...)`s `app_netload.lsp`. | CONFIRMED |
|
||||||
|
| Automatic LISP invocation | source comment in `Program.cs:181`; no installed startup file | `acaddoc.lsp` is named by a comment, but its installed content and registration were not available. | UNKNOWN |
|
||||||
|
| Secondary module load | legacy audit, `OrthoLoader.Ribbon.Initialize` / `OrthoLoader.Initialize` | OrthoLoader reads adjacent Settings.xml and sequentially calls `._netload` for 20 configured modules. | CONFIRMED |
|
||||||
|
|
||||||
|
## P_Rec_Annot-specific implication
|
||||||
|
|
||||||
|
The historic contract is not a direct network `NETLOAD`. It is a local synchronized application directory followed by a generated, per-session AutoLISP loader. A generated LISP includes the Plant project path in its first comment and conditionally emits loads only for existing local plugin catalogs. New code must not reproduce that behavior before deciding whether it is compatible, necessary and safe for Plant 3D 2026.
|
||||||
|
|
||||||
|
The prior legacy audit established that `OrthoLoader.dll` is the initial assembly for P_Rec_Annot. This launcher audit establishes how the installer/bootstrap is intended to make that assembly loadable. Neither audit proves the deployed local files existed or auto-loaded on this workstation.
|
||||||
|
|
||||||
|
## Integration recommendation
|
||||||
|
|
||||||
|
Use one versioned, locally synchronized entry artifact only after a compatibility decision. The minimal pilot should retain the existing installer catalog/target contract, make the bootstrap load exactly one new entry assembly, and use an explicitly reviewed startup mechanism. Do not rely on an unproven AppLoader bundle or add new global `TRUSTEDPATHS`/profile mutations. The generated loader and any project-path comment are legacy behavior, not requirements for the new P_Rec_Annot until runtime validation establishes a need.
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ParentProject,ParentFile,ParentMethod,Executable,Arguments,WorkingDirectory,UseShellExecute,WaitForExit,Conditions,ErrorHandling,Confidence
|
||||||
|
All_Serv_Install,All_Serv_Install/Form1.cs,button launch handler around 195-230,configured Pattern.Object,Predictable quoted Pattern.Attrib values including project path,not set,default,not set,after successful plugin copy,none visible around Process.Start,CONFIRMED
|
||||||
|
All_Serv_InstallUpdater,All_Serv_InstallUpdater/Form1.cs,Form1_Load 28-48,local All_Serv_Install.exe,none,not set,default,not set,after successful MD5 update,closes on update failure,CONFIRMED
|
||||||
|
All_Serv_Load,All_Serv_Load/Form1.cs,launch handler around 180-205,configured Pattern.Object,configured attributes plus selected project path,not set,default,not set,after successful plugin copy,none visible around Process.Start,CONFIRMED
|
||||||
|
P_Serv_Starter,P_Serv_Starter/Program.cs,Main 15-54,C:\AutoCAD User Support\p3d-support\start_plant_3d.bat,none,changed to configured project directory before start,default,not set,after local loader generation and LISP copy,shows message and returns for loader/copy errors,CONFIRMED
|
||||||
|
start_plant_3d.bat,All_Serv_Load/Addins/start_plant_3d.bat,script,C:\Program Files\Autodesk\AutoCAD 2019\acad.exe,/product PLNT3D /language ru-RU,not set,START default,no,after PlantCleaner,script has no error checks,CONFIRMED
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user