Compare commits

..
Author SHA1 Message Date
codex 0e0c13ee00 test: materialize controlled fixture families 2026-08-10 16:35:30 +03:00
codex 07abdc013b release: package P_Rec_Annot 0.2.19 2026-08-10 16:21:38 +03:00
codex 990d057ae3 feat: bridge settings assets and define fixture matrix 2026-08-10 16:20:52 +03:00
codex 8ad3d97edb release: package P_Rec_Annot 0.2.18 2026-08-10 15:46:30 +03:00
codex 6624c4ffd0 feat: model legacy support annotation settings 2026-08-10 15:46:01 +03:00
codex a80ff5220d release: package P_Rec_Annot 0.2.17 2026-08-10 15:45:04 +03:00
codex beadf751b0 feat: model legacy line number marker settings 2026-08-10 15:44:38 +03:00
codex 5047b7d41b release: package P_Rec_Annot 0.2.16 2026-08-10 15:43:17 +03:00
codex f706840d5d fix: preserve legacy settings decimal semantics 2026-08-10 15:42:18 +03:00
codex eb70c9d1a1 release: package p-rec-annot 0.2.15 2026-08-10 15:19:27 +03:00
codex f127fb1ed2 feat: bind controlled evidence to target command 2026-08-10 15:17:12 +03:00
codex 0e53ffb810 release: package p-rec-annot 0.2.14 2026-08-10 15:12:14 +03:00
codex 9242d2bb08 feat: record p-rec-annot selected-view automation evidence 2026-08-10 15:11:40 +03:00
codex 9aad3662ed release: package p-rec-annot 0.2.13 2026-08-10 14:44:55 +03:00
codex eaa6044682 fix: bind p-rec-annot line edit apply permits 2026-08-10 14:44:23 +03:00
codex 87887ffc76 release: package p-rec-annot 0.2.12 2026-08-10 14:39:02 +03:00
codex 9443babaf1 fix: guard p-rec-annot line edit commands 2026-08-10 14:11:58 +03:00
codex bb45add91d release: package p-rec-annot 0.2.11 2026-08-10 13:27:21 +03:00
codex 5bd39d56c4 fix: guard p-rec-annot line refresh apply 2026-08-10 13:26:19 +03:00
codex 4a351d9b44 fix: accept schema-2 composite module manifests 2026-08-10 11:02:22 +03:00
codex 4a84c268f8 feat: restore manual annotation command surface 2026-08-10 09:51:00 +03:00
codex 289b262053 feat: expose ortho maintenance commands in composite ribbon 2026-08-07 15:35:59 +03:00
codex 64ace55338 feat: port p-rec-annot ortho dimension workflow 2026-08-07 15:34:19 +03:00
codex 8510974d75 feat: publish p-rec-annot 0.2.6 source navigation surface 2026-08-07 15:29:35 +03:00
codex 14c244657d feat: add ortho annotation source navigation 2026-08-07 15:26:37 +03:00
codex 79f6124885 feat: expose ortho annotation removal in ribbon 2026-08-07 14:59:53 +03:00
codex 11a8130494 docs: map p-rec-annot release assets and package contract 2026-08-07 14:57:05 +03:00
codex 1986d15c2b feat: add ortho annotation removal and package integrity manifest 2026-08-07 14:51:57 +03:00
codex 3eaa659947 feat: publish p-rec-annot 0.2.3 settings bundle 2026-08-07 14:25:06 +03:00
codex 652515c2ba feat: add typed production settings contract 2026-08-07 14:23:34 +03:00
codex 9a41d831b4 feat: add manual annotation edit and reopen workflow 2026-08-07 14:21:00 +03:00
codex 700922b832 feat: persist p-rec-annot manual annotation state 2026-08-07 14:14:34 +03:00
codex 064cfdd803 feat: expand p-rec-annot user surface and datum controls 2026-08-07 14:00:54 +03:00
codex fcdec4d227 feat: restore p-rec-annot manual annotation palette 2026-08-07 13:36:17 +03:00
codex 461cc8f71b feat: port p-rec-annot ortho batch update lifecycle 2026-08-07 13:27:52 +03:00
codex b381f83bbd feat: port p-rec-annot ortho status and attribute maintenance 2026-08-07 13:21:32 +03:00
codex d4016237d8 feat: port p-rec-annot viewport layer update command 2026-08-07 13:14:53 +03:00
codex aaec3f93f2 feat: port p-rec-annot ortho batch and layer rules 2026-08-07 13:09:50 +03:00
codex 548c73cb27 feat: expand p-rec-annot ortho annotation workflows 2026-08-07 12:51:54 +03:00
codex c57c844f78 feat: port p-rec-annot auto annotate view pipeline 2026-08-07 12:44:07 +03:00
codex 7d8cc570df feat: port p-rec-annot ortho viewport maintenance foundation 2026-08-07 12:43:08 +03:00
codex ba39a649be feat: expand p-rec-annot component geometry adapter 2026-08-07 12:00:00 +03:00
codex b69a858f8e feat: port p-rec-annot elevation subsystem 2026-08-07 11:58:13 +03:00
codex 532c9dc1f2 feat: complete p-rec-annot line connection pipeline 2026-08-07 09:59:54 +03:00
codex 01a0b711d0 feat: validate p-rec-annot connector view eligibility 2026-08-07 09:48:22 +03:00
codex acfa6723c9 feat: read p-rec-annot connector ports 2026-08-07 09:47:20 +03:00
codex 53d6378cfa feat: plan p-rec-annot connector annotations 2026-08-07 09:45:07 +03:00
codex 20ac5e4808 feat: add p-rec-annot Plant connector relationship reader 2026-08-07 09:37:43 +03:00
codex f9cd0a43bf fix: preserve legacy p-rec-annot elevation quotient formatting 2026-08-07 09:32:11 +03:00
codex a8749ffa69 feat: port p-rec-annot line connection boundary rules 2026-08-07 09:24:32 +03:00
codex 43c9e42b5b feat: add p-rec-annot Plant component geometry adapter 2026-08-07 09:22:16 +03:00
codex 95ae9dfd18 feat: port p-rec-annot line-number refresh pipeline 2026-08-07 09:19:56 +03:00
codex 34cdfa225c build: issue p-rec-annot composite candidate 0.2.1 2026-08-07 09:14:43 +03:00
codex 2626b64e63 feat: activate p-rec-annot line-number edit commands 2026-08-07 09:09:47 +03:00
codex 6e909f89bc feat: establish p-rec-annot composite module manifest 2026-08-07 09:06:49 +03:00
codex 91f316d79b feat: port p-rec-annot line-number edit mutations 2026-08-07 09:04:07 +03:00
codex 7090321167 feat: add p-rec-annot line connection planning 2026-08-07 09:00:25 +03:00
codex 6f39bb204d feat: add p-rec-annot elevation annotation planning 2026-08-07 08:59:18 +03:00
codex 3128568965 feat: add controlled p-rec-annot line-number apply 2026-08-07 08:58:38 +03:00
codex 58f71267a2 feat: add p-rec-annot interactive line-number preview 2026-08-07 08:54:31 +03:00
codex 066a9f3d94 feat: add p-rec-annot Plant line-number candidate reader 2026-08-07 08:48:42 +03:00
codex 0a7f05989a feat: add p-rec-annot line-number command preview service 2026-08-07 08:47:48 +03:00
codex 315d36a36a test: cover p-rec-annot line-number list planning 2026-08-07 08:42:34 +03:00
codex 431366c27c feat: add p-rec-annot line-number list writer 2026-08-07 08:41:19 +03:00
codex 172d0fa0f1 feat: port p-rec-annot elevation formatting foundation 2026-08-07 08:38:19 +03:00
codex d3da634894 feat: add p-rec-annot line-number marker writer 2026-08-07 08:34:55 +03:00
codex a17ad87fc2 feat: port p-rec-annot line-number asset settings 2026-08-07 08:32:15 +03:00
codex ed7218983b feat: add p-rec-annot line-number apply preflight 2026-08-07 08:28:47 +03:00
codex 10af8c8f12 feat: port p-rec-annot line-number command planning 2026-08-07 08:22:40 +03:00
codex b6a2cd06af feat: add p-rec-annot datalinks line-number read path 2026-08-07 08:18:55 +03:00
codex 8e197cdcde build: resolve local plant 3d 2026 managed references 2026-08-07 08:14:19 +03:00
codex 7070e4dffe feat: port p-rec-annot extents intersection rules 2026-08-07 08:09:09 +03:00
codex cce3ff71c5 feat: port p-rec-annot line-number formatting foundation 2026-08-07 08:08:17 +03:00
codex 70ae18981a fix: make p-rec-update initialization load-safe 2026-08-07 07:34:54 +03:00
codex 20960ae91f fix: remove generated p-rec-update artifacts 2026-08-06 16:41:10 +03:00
codex ca0f49ee5a feat: add p-rec-update plant 3d 2026 slice 2026-08-06 16:40:38 +03:00
codex c3ac183fff docs: link shared project map from agent instructions 2026-08-05 07:14:45 +03:00
codex 68f500692c fix: verify apply duplicate and capture runtime failures 2026-08-04 16:35:42 +03:00
codex 86cabb584b fix: import legacy Pipe annotation template 2026-08-04 14:59:51 +03:00
codex aefd3aa487 fix: finalize script-driven limited Apply evidence 2026-08-04 14:49:54 +03:00
codex 07ab3c436f fix: open source database for Ortho placement 2026-08-04 14:43:59 +03:00
codex c97f02c550 fix: identify failed Ortho pipe placement boundary 2026-08-04 14:34:15 +03:00
codex 62f470c7ba fix: retain Ortho placement failure stack trace 2026-08-04 14:32:30 +03:00
codex 2fe1ca71df fix: diagnose unavailable Ortho pipe placement 2026-08-04 14:30:29 +03:00
codex e707456ab6 fix: retain prepared Ortho context for script PXP-OA 2026-08-04 14:25:44 +03:00
codex 3cecee4777 fix: bind script PXP-OA planning to command document 2026-08-04 14:19:20 +03:00
codex 8a9f95a605 fix: correlate script-driven PXP-OA command events 2026-08-04 14:09:14 +03:00
codex c270a182ce fix: verify PXP-OA registration for script Preview 2026-08-04 12:23:36 +03:00
codex 25311e89f0 Merge remote-tracking branch 'gitea/design/autodesk-launcher-next-20260731' into migration/plant3d-2026 2026-08-04 11:24:32 +03:00
codex f5a70df68a Merge remote-tracking branch 'gitea/audit/launcher-source-architecture-20260731' into migration/plant3d-2026 2026-08-04 11:24:32 +03:00
codex 5725bc8737 Merge remote-tracking branch 'gitea/audit/p-rec-annot-legacy-loading-20260731' into migration/plant3d-2026 2026-08-04 11:24:32 +03:00
codex bfd31a3747 fix: activate fixture project in script preparation 2026-08-04 10:10:49 +03:00
codex c5cf142159 refactor: observe commands driven by AutoCAD automation script 2026-08-04 10:06:57 +03:00
codex bc481d4378 fix: execute automation commands in AutoCAD command context 2026-08-04 09:37:24 +03:00
codex 1fa25809de fix: queue readiness command for active fixture document 2026-08-04 09:26:43 +03:00
codex 6ed1e4086b fix: activate fixture command queue before dispatch 2026-08-04 07:39:21 +03:00
codex c633107273 fix: terminate automation commands through AutoCAD queue 2026-08-04 07:37:56 +03:00
codex 2d9fbdd474 fix: queue readiness barrier after fixture activation 2026-08-04 07:29:37 +03:00
codex 98564b807f fix: wait for AutoCAD command-queue readiness barrier 2026-08-04 07:28:32 +03:00
codex 1b4ff816ba fix: match active fixture document by stable name 2026-08-04 07:26:45 +03:00
codex 13578b7c9b fix: recover fixture activation raised during document open 2026-08-04 07:25:53 +03:00
codex 523d063ceb fix: preserve completed automation evidence when quit is unavailable 2026-08-04 07:22:27 +03:00
codex 31171c9ed8 test: correct lifecycle exit assertions 2026-08-03 17:24:13 +03:00
codex edf1640c4f fix: dispatch lifecycle exit through live document 2026-08-03 17:23:44 +03:00
codex 81a075e684 fix: exit lifecycle probe without document command queue 2026-08-03 17:21:08 +03:00
codex 32607e2f34 fix: defer Plant activation until document is quiescent 2026-08-03 17:19:01 +03:00
codex c9c4113e86 fix: queue native quit after automation commands 2026-08-03 17:14:14 +03:00
codex 33f91c26aa fix: close all automation-opened documents 2026-08-03 17:10:02 +03:00
codex ef615a1f8e fix: dispatch automation exit from final command 2026-08-03 17:07:40 +03:00
codex e7765cbdda fix: close live document before automation exit 2026-08-03 17:04:56 +03:00
codex d8e61b7a5e fix: exit lifecycle probe from verified idle 2026-08-03 17:02:18 +03:00
codex 79067b72de fix: contain automation callback failures 2026-08-03 16:58:18 +03:00
codex 44de0cf787 fix: dispatch controlled AutoCAD exit from idle 2026-08-03 16:49:57 +03:00
codex 239796aafe fix: arm automation on post-netload command 2026-08-03 16:43:32 +03:00
codex 8a1735408a fix: trace gated automation bootstrap 2026-08-03 16:41:15 +03:00
codex 544b329077 fix: defer Plant automation to first post-startup idle 2026-08-03 16:34:17 +03:00
codex 1563e2fae0 feat: expose limited Apply preview candidates 2026-08-03 11:00:13 +03:00
codex bd5f47d811 feat: implement first limited Pipe annotation Apply 2026-08-03 10:50:51 +03:00
codex 1bfb91bc4b feat: add first limited Pipe Apply preflight 2026-08-03 10:37:37 +03:00
codex 209b8b89b8 docs: record control versus DryRun project deltas 2026-08-03 10:25:49 +03:00
codex 31787fc05a docs: strengthen P_Rec_Annot mapping and Apply rules 2026-08-03 10:14:29 +03:00
codex 11cf97c476 docs: document legacy and Plant 3D 2026 viewport mapping APIs 2026-08-03 10:14:21 +03:00
codex 76d362d2c3 feat: resolve Ortho viewport membership from confirmed evidence 2026-08-03 10:14:11 +03:00
codex 37d3cacc13 docs: record DryRun plan determinism evidence 2026-08-03 09:18:20 +03:00
codex 713e620485 feat: add deterministic annotation plan canonicalization 2026-08-03 09:14:19 +03:00
codex 6f32371418 docs: record source-model DataLinks runtime evidence 2026-08-03 09:08:47 +03:00
codex 243080a1aa feat: resolve Ortho source using confirmed ModelFiles metadata 2026-08-03 09:08:38 +03:00
codex 75c27ee8e5 docs: define Ortho source DataLinks contract 2026-08-03 08:32:46 +03:00
codex c83ff3d9b3 docs: define Plant 3D 2026 migration agent contract 2026-08-03 08:32:23 +03:00
codex 09c53be7f4 fix: read Plant 3D model file references 2026-08-03 08:28:41 +03:00
codex bdd2c9c888 fix: resolve string Ortho model references 2026-08-03 08:18:44 +03:00
codex 95dba856c8 feat: resolve Ortho views to source model drawings 2026-08-03 08:16:11 +03:00
codex 6fa3af6eda docs: record real Ortho pipe DryRun evidence 2026-08-03 08:09:45 +03:00
codex c616188336 fix: tolerate missing Ortho DataLinks rows in DryRun 2026-08-03 07:54:00 +03:00
codex bb44b1b1aa fix: resolve Plant project context diagnostics 2026-07-31 15:57:06 +03:00
codex 6e4cc74e82 docs: define Launcher Next pilot and performance plan 2026-07-31 14:34:05 +03:00
codex 1127a8376f docs: define Autodesk launcher next architecture 2026-07-31 14:30:19 +03:00
codex 36178374db audit: document launcher source architecture 2026-07-31 14:06:39 +03:00
codex b33351117b audit: clarify legacy bootstrap evidence 2026-07-31 13:39:55 +03:00
codex 56dcce42f6 audit: document legacy P_Rec_Annot loading chain 2026-07-31 13:39:29 +03:00
codex ea3a9e157e test: cover deterministic Ortho planning 2026-07-31 13:08:22 +03:00
codex 837ac1d994 feat: read Plant 3D Ortho context and viewports 2026-07-31 13:08:16 +03:00
codex 142b5f8b93 test: add controlled Plant 3D 2026 runtime smoke mode 2026-07-31 12:55:22 +03:00
codex dcf95d7e3a test: validate Plant 3D 2026 build and bundle 2026-07-31 11:37:36 +03:00
codex c733a27cbb feat: register real Plant 3D 2026 commands and ribbon 2026-07-31 11:37:31 +03:00
codex 9a7a6cd678 fix: bind P_Rec_Annot to Plant 3D 2026 assemblies 2026-07-31 11:36:46 +03:00
codex 66e52e5f50 chore: accept user-approved report presentation updates
Accept the three existing user changes as approved by the project owner. The changes are intentionally committed as-is without semantic normalization or regeneration.
2026-07-31 10:03:39 +03:00
codex 60b3934cd2 feat: scaffold P_Rec_Annot .NET 8 architecture 2026-07-31 09:57:53 +03:00
codex 34222cedc8 audit: inventory P_Rec_Annot v1.0.4 sources 2026-07-31 09:37:32 +03:00
codex 596c95a4b6 fix: aggregate Plant3D plugin usage without computer field 2026-07-31 09:07:06 +03:00
codex 6f29a5bce4 audit: parse Plant3D usage from Excel reports 2026-07-31 08:45:35 +03:00
codex 871499fa03 audit: build plugin usage matrix from logs 2026-07-31 08:00:19 +03:00
codex bbe87611bf audit: inventory P_Rec_BOP Plant 3D 2019 sources 2026-07-31 07:34:41 +03:00
codex 25e1def353 fix: make STA Word queue lifetime safe 2026-07-30 16:09:15 +03:00
codex f7fa0363e3 feat: add learned document template workflow 2026-07-30 15:24:51 +03:00
codex 11e6e5d806 fix: import DOCX body without source layout 2026-07-30 14:24:08 +03:00
codex 77a62d4564 fix: restore DOCX workbench runtime path 2026-07-30 14:00:31 +03:00
codex dab90f0780 test: keep COM integration validation explicit 2026-07-30 13:20:06 +03:00
codex 3f2fbe9883 test: add workbench tests and MVP documentation 2026-07-30 13:19:04 +03:00
codex 86fb8c89d2 feat: add standards workbench and portable rules profiles 2026-07-30 13:18:59 +03:00
codex 958bad6823 feat: add DOCX workbench and Word automation 2026-07-30 13:18:54 +03:00
codex 1bda2c155d feat: scaffold document automation workbench solution 2026-07-30 13:18:49 +03:00
363 changed files with 58657 additions and 649 deletions
+13
View File
@@ -1,2 +1,15 @@
/link/
/link
# Generated build output is never source or package input.
**/bin/
**/obj/
# 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
+163
View File
@@ -0,0 +1,163 @@
# AGENTS.md
## Назначение проекта
Этот репозиторий содержит аудит, архитектуру, исходный код, тесты, runtime
evidence и документацию миграции корпоративной экосистемы Autodesk AutoCAD
Plant 3D 2019 на Autodesk AutoCAD Plant 3D 2026.
Конечная цель — функционально эквивалентная рабочая схема Plant 3D 2026:
корпоративный Launcher, локальная доставка и активация пакетов, managed
плагины, команды, Ribbon, Plant project integration, DataLinks, Ortho,
аннотации, обновление, диагностика и rollback. Миграция не считается
завершённой после успешной компиляции: необходима runtime-проверка реальных
сценариев в Plant 3D 2026.
## Связанные репозитории
- основной Autodesk-репозиторий: `C:\dev\autodesk`;
- Launcher Next: `C:\dev\launcher-next`;
- legacy launcher (read-only): `C:\dev\launcher\git`, `C:\dev\p_serv_starter`;
- legacy P_Rec_Annot (read-only): `C:\dev\autodesk\link\P_Rec_Annot\v1.0.4\repo`.
Legacy и deployed-файлы нельзя изменять без отдельного прямого задания.
## Целевая платформа и принцип миграции
- Autodesk AutoCAD Plant 3D 2026, series R25.1, Windows x64, .NET 8;
- использовать реальные Autodesk 2026 managed assemblies;
- managed package загружается локально, не напрямую с UNC;
- Autodesk DLL нельзя коммитить, помещать в package/fixture или публиковать.
Переносится функциональное поведение, а не историческая структура файлов. Не
воспроизводить без отдельного доказательства 20 последовательных `NETLOAD`,
legacy LISP bootstrap, `OrthoLoader.dll`, загрузку с сети или прежние
workaround. Предпочтительная цепочка:
`Launcher Next → versioned local package → один managed entry assembly → internal dependencies → commands and Ribbon`.
## Статус legacy-команд
Ни одна активная команда Plant 3D 2019 не исчезает молча. Для каждой команды
фиксируется ровно один статус: `IMPLEMENTED`, `RUNTIME_VERIFIED`,
`REPLACED_WITH_NATIVE`, `RETIRED_APPROVED` или `BLOCKED_WITH_REASON`.
Aliases сохраняются, пока их удаление не утверждено отдельно.
## Runtime уровни
`Smoke` проверяет assembly load, регистрацию команд, Plant context, Ribbon,
отсутствие необработанных исключений и отсутствие записи в DWG.
`DryRun` проверяет реальные Plant data, Ortho/sheet/view definitions, DataLinks,
Plant objects, детерминированный план и отсутствие записи в DWG/project DB.
`Apply` разрешается только отдельным явным заданием: write guard, ограниченная
DWG transaction, тестовая копия проекта, baseline, rollback/UNDO, duplicate
protection и runtime evidence обязательны. Apply нельзя включать только
локальной конфигурацией.
## Runtime важнее предположений
Build и unit tests не доказывают Autodesk integration. Для
`RUNTIME_VERIFIED` необходимы фактические результаты: команда, module path,
DBMOD, Ribbon, DataLinks, Ortho status, telemetry и состояние файлов после
закрытия. Не выдумывать UI evidence: использовать `null`, `NOT VERIFIED` или
`BLOCKED WITH REASON`.
На тестовой машине реальный Plant 3D запуск разрешён, если он входит в задачу
и есть test branch, локальный fixture, пройденные build/tests, rollback и нет
production deploy. Не создавать искусственный review-gate перед Process.Start.
Остановка допустима лишь после завершения доступной части работы и при
конкретном внешнем blocker: установка, ОС, права, Autodesk runtime, fixture или
необходимость production-данных.
## Test fixtures и отсутствие записи
Fixtures создаются вне Git, например `<TEST_FIXTURE_ROOT>`. Ortho fixture —
настоящий Plant project с Pipe, DataLinks row, line group, Ortho drawing,
Ortho viewport и source-model connection; обычный AutoCAD viewport не годится.
Production DWG/project использовать нельзя без прямого разрешения.
Нельзя коммитить DWG, DCF, Project.xml с runtime-data, project DB, Ortho
generated files или реальные engineering values.
Для Smoke/DryRun проверять DBMOD до/после, SHA-256 содержательных project
files, отсутствие непредусмотренных transactions/DCF/project-metadata writes.
`LastWriteTime` недостаточен. Отделять служебные Plant cache/session files от
содержательных данных.
## Ribbon, механика и evidence
Ribbon — часть продукта: проверять tab, panel, button, command binding, stable
IDs, отсутствие дубликатов при документах/workspace/reinitialization. Команда
может быть отдельно рабочей, но plugin slice не завершён без Ribbon evidence
или отдельного UI defect.
Документировать каждую runtime-цепочку: component/project/class/method,
input/output, Autodesk transaction mode, writes, telemetry, failure code и
rollback action. Документация соответствует фактическому коду.
Evidence содержит только проверенные значения. Не добавлять username, permit,
полный environment, real project paths/line numbers/ObjectId/RowId/properties,
полные logs, local manifest или startup scripts. Путь fixture заменять на
`<TEST_FIXTURE_ROOT>`.
## Warnings и Git
Известный `MSB3277` WindowsBase 4.0.0.0/8.0.0.0 допустим только при полном
совпадении с ранее зафиксированным warning. Новые warnings исследовать; не
подавлять `NoWarn` без причины.
Запрещены force push, amend/rebase опубликованной истории, push binaries,
изменение неизвестного remote и production deployment из test branch. Перед
commit выполнять:
```powershell
git diff --check
git status --short
git diff --cached --check
```
Не смешивать Launcher defect, plugin defect, runtime evidence и unrelated
formatting в одном commit.
## Definition of Done
Срез завершён, когда реализованы код, build/tests, валидный package и требуемый
runtime-сценарий; записаны фактические результаты; выполнен rollback; fixture и
production изолированы; механика документирована; ветка опубликована обычным
push.
## Общая карта проекта
Каноническая общая карта проекта находится в Google Drive:
`https://drive.google.com/drive/folders/1avnR0V8q7y5pfWRYwB6LPBEEyfpN7PpV`.
Локальная синхронизированная копия, если она доступна, определяется только
переменной среды `PHIM_PLANT3D_PROJECT_MAP_ROOT`; её путь не хардкодить.
Перед работой над архитектурой, миграцией или runtime-сценарием обязательно
прочитать доступные индекс/обзор карты, целевую архитектуру и migration status,
индекс runtime evidence, а также записи карты для затрагиваемых Launcher и
P_Rec_Annot. Затем сверить их с локальными AGENTS.md, релевантными tracked
документами, текущим кодом и тестами.
Источники истины имеют следующий приоритет: фактическое runtime evidence;
текущий код, tests и tracked configuration на проверенном Git HEAD; tracked
документация репозитория; общая карта проекта. Внешняя карта описывает и
связывает работу, но не является build/runtime dependency и не переопределяет
текущий код или runtime evidence.
Проверять свежесть каждой используемой записи карты по фактическим `git
rev-parse HEAD`, ветке и привязанному runtime evidence. Если карта или её
локальная копия недоступна, фиксировать `PROJECT_MAP_UNAVAILABLE`; если
зафиксированные commit/runtime status не совпадают с фактическими —
`PROJECT_MAP_STALE`. Эти статусы не разрешают выдумывать evidence: продолжать
по доступным локальным источникам и явно сообщать расхождение.
Системные инварианты сохраняются: Launcher доставляет versioned local package
с одним managed entry assembly и без Autodesk DLL; P_Rec_Annot подтверждает
реальное Plant/Ortho/DataLinks поведение runtime evidence, а не только build
или tests. После изменения архитектуры либо runtime-статуса в отчёте указывать
конкретные документы общей карты, которые требуют обновления (архитектура,
статус миграции, evidence index и запись затронутой подсистемы); не заявлять,
что они уже обновлены, без фактического изменения.
+9
View File
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
+206
View File
@@ -0,0 +1,206 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentAutomation.Shared", "src\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj", "{3D48D7EE-4C18-47A8-9F8F-E296491C0463}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.App", "src\DocxWorkbench.App\DocxWorkbench.App.csproj", "{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.Core", "src\DocxWorkbench.Core\DocxWorkbench.Core.csproj", "{5D7A031E-06AE-4725-89D8-C4EAD75700B1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.OpenXml", "src\DocxWorkbench.OpenXml\DocxWorkbench.OpenXml.csproj", "{6560936F-10A6-406E-8957-24E577A3F7EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.WordInterop", "src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj", "{3349BB3A-001A-480D-9867-CF7199BDA04B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.App", "src\StandardsWorkbench.App\StandardsWorkbench.App.csproj", "{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.Core", "src\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj", "{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.Pdf", "src\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj", "{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.Ollama", "src\StandardsWorkbench.Ollama\StandardsWorkbench.Ollama.csproj", "{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentAutomation.IntegrationTests", "tests\DocumentAutomation.IntegrationTests\DocumentAutomation.IntegrationTests.csproj", "{6D7ED423-F943-4DF0-8D18-F69C127F3D67}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxWorkbench.Tests", "tests\DocxWorkbench.Tests\DocxWorkbench.Tests.csproj", "{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardsWorkbench.Tests", "tests\StandardsWorkbench.Tests\StandardsWorkbench.Tests.csproj", "{6E533AAB-42E6-4C60-AE06-A89F2B283187}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|x64.ActiveCfg = Debug|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|x64.Build.0 = Debug|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|x86.ActiveCfg = Debug|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Debug|x86.Build.0 = Debug|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|Any CPU.Build.0 = Release|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|x64.ActiveCfg = Release|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|x64.Build.0 = Release|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|x86.ActiveCfg = Release|Any CPU
{3D48D7EE-4C18-47A8-9F8F-E296491C0463}.Release|x86.Build.0 = Release|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|x64.ActiveCfg = Debug|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|x64.Build.0 = Debug|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|x86.ActiveCfg = Debug|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Debug|x86.Build.0 = Debug|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|Any CPU.Build.0 = Release|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|x64.ActiveCfg = Release|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|x64.Build.0 = Release|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|x86.ActiveCfg = Release|Any CPU
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5}.Release|x86.Build.0 = Release|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|x64.ActiveCfg = Debug|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|x64.Build.0 = Debug|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|x86.ActiveCfg = Debug|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Debug|x86.Build.0 = Debug|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|Any CPU.Build.0 = Release|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|x64.ActiveCfg = Release|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|x64.Build.0 = Release|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|x86.ActiveCfg = Release|Any CPU
{5D7A031E-06AE-4725-89D8-C4EAD75700B1}.Release|x86.Build.0 = Release|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|x64.ActiveCfg = Debug|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|x64.Build.0 = Debug|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|x86.ActiveCfg = Debug|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Debug|x86.Build.0 = Debug|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|Any CPU.Build.0 = Release|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|x64.ActiveCfg = Release|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|x64.Build.0 = Release|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|x86.ActiveCfg = Release|Any CPU
{6560936F-10A6-406E-8957-24E577A3F7EE}.Release|x86.Build.0 = Release|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|x64.ActiveCfg = Debug|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|x64.Build.0 = Debug|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|x86.ActiveCfg = Debug|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Debug|x86.Build.0 = Debug|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|Any CPU.Build.0 = Release|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|x64.ActiveCfg = Release|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|x64.Build.0 = Release|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|x86.ActiveCfg = Release|Any CPU
{3349BB3A-001A-480D-9867-CF7199BDA04B}.Release|x86.Build.0 = Release|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|x64.ActiveCfg = Debug|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|x64.Build.0 = Debug|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|x86.ActiveCfg = Debug|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Debug|x86.Build.0 = Debug|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|Any CPU.Build.0 = Release|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|x64.ActiveCfg = Release|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|x64.Build.0 = Release|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|x86.ActiveCfg = Release|Any CPU
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5}.Release|x86.Build.0 = Release|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|x64.ActiveCfg = Debug|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|x64.Build.0 = Debug|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|x86.ActiveCfg = Debug|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Debug|x86.Build.0 = Debug|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|Any CPU.Build.0 = Release|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|x64.ActiveCfg = Release|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|x64.Build.0 = Release|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|x86.ActiveCfg = Release|Any CPU
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D}.Release|x86.Build.0 = Release|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|x64.ActiveCfg = Debug|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|x64.Build.0 = Debug|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|x86.ActiveCfg = Debug|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Debug|x86.Build.0 = Debug|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|Any CPU.Build.0 = Release|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|x64.ActiveCfg = Release|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|x64.Build.0 = Release|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|x86.ActiveCfg = Release|Any CPU
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1}.Release|x86.Build.0 = Release|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|x64.ActiveCfg = Debug|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|x64.Build.0 = Debug|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|x86.ActiveCfg = Debug|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Debug|x86.Build.0 = Debug|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|Any CPU.Build.0 = Release|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|x64.ActiveCfg = Release|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|x64.Build.0 = Release|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|x86.ActiveCfg = Release|Any CPU
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9}.Release|x86.Build.0 = Release|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|x64.ActiveCfg = Debug|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|x64.Build.0 = Debug|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|x86.ActiveCfg = Debug|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Debug|x86.Build.0 = Debug|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|Any CPU.Build.0 = Release|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|x64.ActiveCfg = Release|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|x64.Build.0 = Release|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|x86.ActiveCfg = Release|Any CPU
{6D7ED423-F943-4DF0-8D18-F69C127F3D67}.Release|x86.Build.0 = Release|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|x64.ActiveCfg = Debug|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|x64.Build.0 = Debug|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|x86.ActiveCfg = Debug|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Debug|x86.Build.0 = Debug|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|Any CPU.Build.0 = Release|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|x64.ActiveCfg = Release|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|x64.Build.0 = Release|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|x86.ActiveCfg = Release|Any CPU
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE}.Release|x86.Build.0 = Release|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|x64.ActiveCfg = Debug|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|x64.Build.0 = Debug|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|x86.ActiveCfg = Debug|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Debug|x86.Build.0 = Debug|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|Any CPU.Build.0 = Release|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|x64.ActiveCfg = Release|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|x64.Build.0 = Release|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|x86.ActiveCfg = Release|Any CPU
{6E533AAB-42E6-4C60-AE06-A89F2B283187}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3D48D7EE-4C18-47A8-9F8F-E296491C0463} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{BB7057EA-13F7-4F9E-BC1F-6E4FE0C8C9D5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5D7A031E-06AE-4725-89D8-C4EAD75700B1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6560936F-10A6-406E-8957-24E577A3F7EE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{3349BB3A-001A-480D-9867-CF7199BDA04B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4A79AA2B-7E40-456A-9BBF-D6F8A444EDC5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{71C7E7B1-1E3E-470B-A942-EFCD58CC692D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{463BCC3A-3836-4DAC-A2A1-B786A9B263F1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{7EA1410E-03BF-46B3-9C6D-20983C4FA2C9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6D7ED423-F943-4DF0-8D18-F69C127F3D67} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{8539CA78-AB1A-499E-9C9C-C0FBBFC7AFAE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{6E533AAB-42E6-4C60-AE06-A89F2B283187} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+15
View File
@@ -0,0 +1,15 @@
# DocumentAutomation
Первый локальный прототип состоит из независимых `DOCX Workbench` и `Standards Workbench` на .NET 8 Windows Forms.
DOCX Workbench анализирует DOCX через Open XML, формирует документ через локальный Microsoft Word COM, сохраняет шаблонные секции/колонтитулы, обновляет поля и экспортирует PDF. Standards Workbench импортирует PDF, сохраняет checksum, извлекает страницы и кандидатные пункты, обращается к Ollama только за предложением candidate-rule и экспортирует переносимый профиль.
## Ограничения MVP
- OCR только обозначен интерфейсом; страницы без текстового слоя получают статус `OCR_REQUIRED`.
- Автоматическое утверждение нормативных правил запрещено.
- Word COM требует установленного Microsoft Word и запускается только интерактивно, не как service.
- Реальный `156-титул-ПД.docx` и нормативные PDF не добавлялись в Git.
- PDF preview использует локальный Word ExportAsFixedFormat.
Подробности: `docs/architecture.md`, `docs/docx-template-contract.md`, `docs/word-com-lifecycle.md`, `docs/standards-rule-model.md`.
+9
View File
@@ -0,0 +1,9 @@
# Architecture
`DocxWorkbench.App` and `StandardsWorkbench.App` are separate WinForms applications. They share only DTOs in `DocumentAutomation.Shared`.
DOCX flow: GUI -> `DocxWorkbench.Core` contracts -> `DocxWorkbench.OpenXml` read-only inspection or `DocxWorkbench.WordInterop` sequential Word COM queue -> DOCX/PDF and technical JSON log.
Standards flow: GUI -> `StandardsWorkbench.Pdf` page/clause extraction and `StandardsWorkbench.Ollama` candidate-only suggestions -> manually approved portable profile -> `StandardsWorkbench.Core.RulesEngine`.
COM is isolated from Core and UI. Standards rules are not hardcoded into document generation.
+9
View File
@@ -0,0 +1,9 @@
# Development
```powershell
dotnet restore .\DocumentAutomation\DocumentAutomation.sln
dotnet build .\DocumentAutomation\DocumentAutomation.sln --configuration Release
dotnet test .\DocumentAutomation\DocumentAutomation.sln --configuration Release
```
Run `DocxWorkbench.App` or `StandardsWorkbench.App` from Visual Studio/VS Code. Microsoft Word is required only for DOCX generation and PDF export. Ollama is configured at `http://192.168.200.112:11434`; the model must be selected from `/api/tags`.
@@ -0,0 +1,7 @@
# DOCX template contract
The preferred insertion point is a Content Control with tag or title `DOC_BODY`, then a bookmark named `DOC_BODY`, then the exact marker `{{DOC_BODY}}`. If none is present, generation fails explicitly; the body is never silently appended.
The template is copied to a temporary working file. Body headers/footers are not imported. Template sections and headers/footers are retained, fields are updated, and Word repaginates before saving.
Before Word starts, the template is checked for the insertion point. If none is found, the build is rejected without starting Word. The GUI routes `.docx` through Open XML and `.txt` through `TextDocumentInspector`; template and body analysis results remain independent.
@@ -0,0 +1,5 @@
# Standards rule model
PDF metadata includes SHA-256, page count, text-layer status and import time. Pages without text are marked `OCR_REQUIRED`; no text is invented. Clause extraction preserves clause number, text, page and confidence.
Ollama receives only a small clause context and may return a candidate JSON rule. Candidates remain `New` until human approval. Only `Approved`, `Implemented` or `Tested` rules are evaluated. Profiles contain manifest, approved rules, references and a signature hash.
@@ -0,0 +1,14 @@
# Learned document templates
DOCX Workbench supports two deterministic workflows:
1. `TemplateLearningService` normalizes a `.doc` or copies a `.docx` into a temporary working directory, inspects it, adds a safe `{{DOC_BODY}}` insertion marker to the working copy, and writes a portable `.dtemplate` ZIP package.
2. `TemplateDocumentGenerator` opens the package, replaces explicitly supplied scalar tokens, imports a `.doc`, `.docx`, or `.txt` body through the existing main-story importer, and creates DOCX, PDF, generation, and validation outputs.
The package contains `manifest.json`, `template.docx`, source metadata, page archetypes, regions, slots, field bindings, mapping suggestions, and a validation profile. It contains no original source binary unless it is the normalized template copy.
All Word automation is late-bound and serialized through the existing STA queue. Binary `.doc` sources are opened read-only, macros are disabled through Word automation security, and the source SHA-256 is checked before and after normalization. The source body’s headers, footers, section properties, and page setup are excluded by `DocxBodyImporter`.
The current UI exposes three modes: template learning, document generation, and the existing quick `DOC_BODY` path. Page archetypes and slot proposals are deterministic suggestions and require later user confirmation; Ollama is not required for the workflow.
Known limitation: the first slice stores the learned structural model and default slots, but does not yet provide a pixel-level page editor or automatic extraction of every scalar field from an arbitrary engineering drawing. Those operations remain explicit user mapping work.
@@ -0,0 +1,5 @@
# Word COM lifecycle
Word is resolved late-bound through `Word.Application`; the Word PIA and `office.dll` are not required at application load time. Each service owns one serial `StaWordQueue`, and every Word job runs on its dedicated STA thread with `Visible=false` and alerts disabled. The service opens a working copy, performs one operation, updates fields, repaginates, saves, closes the Document, quits the Application, and releases owned COM proxies in `finally`.
The prototype does not run as a Windows Service, does not kill unrelated `WINWORD.EXE` processes, and does not execute macros. Integration tests compare process IDs before and after each job and verify that no newly-created Word process remains.
View File
+3
View File
@@ -0,0 +1,3 @@
# Samples
Реальные проектные DOCX/PDF и нормативные документы сюда не добавляются. Тесты создают синтетические документы во временном каталоге. Для локальной проверки укажите входные файлы через GUI.
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,171 @@
using System.Text.Json.Serialization;
namespace DocumentAutomation.Shared;
public sealed record InspectionWarning
{
public required string Code { get; init; }
public required string Message { get; init; }
public string? Part { get; init; }
public string? Location { get; init; }
public string? MatchedValue { get; init; }
public string? DetectionRule { get; init; }
}
public sealed record DocumentFieldInfo
{
public required string Type { get; init; }
public required string Part { get; init; }
public int? Section { get; init; }
public string? Instruction { get; init; }
}
public sealed record DocumentImageSummary
{
public int RasterImages { get; init; }
public int DrawingObjects { get; init; }
public int VmlShapes { get; init; }
public int ExternalImages { get; init; }
}
public sealed record DocumentStyleInfo
{
public required string StyleId { get; init; }
public string? Name { get; init; }
public string? Type { get; init; }
public bool IsDefault { get; init; }
public string? BasedOn { get; init; }
public string? LinkedStyle { get; init; }
public int UsageCount { get; init; }
}
public sealed record SectionPartAssignment
{
public required int Section { get; init; }
public IReadOnlyList<string> Headers { get; init; } = [];
public IReadOnlyList<string> Footers { get; init; } = [];
public bool LinkToPrevious { get; init; }
}
public sealed record InsertionPointInfo
{
public required string Type { get; init; }
public string? Location { get; init; }
}
public enum SectionBreakPolicy { Remove, ConvertToParagraph, ConvertToPageBreak }
public enum RevisionImportPolicy { PreserveAndWarn, AcceptAll, RejectAll, BlockImport }
public enum CommentImportPolicy { PreserveAndWarn, Exclude, BlockImport }
public enum StyleConflictPolicy { KeepSourceFormatting, UseDestinationStyles, MapKnownStyles }
public sealed record WordOperationProgress
{
public required string Stage { get; init; }
public string? Message { get; init; }
public int? Percent { get; init; }
}
public sealed record BodyImportPlan
{
public required int ParagraphCount { get; init; }
public required int TableCount { get; init; }
public required int InlineShapeCount { get; init; }
public required int FloatingShapeCount { get; init; }
public required int InternalSectionBreakCount { get; init; }
public required int PageBreakCount { get; init; }
public required IReadOnlyList<string> IncludedStories { get; init; }
public required IReadOnlyList<string> ExcludedStories { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
}
public sealed record DocxBodyImportRequest
{
public required string SourcePath { get; init; }
public required object DestinationRangeHandle { get; init; }
public SectionBreakPolicy SectionBreakPolicy { get; init; } = SectionBreakPolicy.ConvertToPageBreak;
public RevisionImportPolicy RevisionPolicy { get; init; } = RevisionImportPolicy.PreserveAndWarn;
public CommentImportPolicy CommentPolicy { get; init; } = CommentImportPolicy.Exclude;
public StyleConflictPolicy StyleConflictPolicy { get; init; } = StyleConflictPolicy.KeepSourceFormatting;
}
public sealed record BodyImportResult
{
public required BodyImportPlan Plan { get; init; }
public required string InsertionMode { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
}
public sealed record DocumentInspectionResult
{
public required string FilePath { get; init; }
public long FileSize { get; init; }
public DateTime LastWriteTimeUtc { get; init; }
public int Sections { get; init; }
public int Paragraphs { get; init; }
public int Tables { get; init; }
public int Images { get; init; }
public DocumentImageSummary ImageSummary { get; init; } = new();
public int Headers { get; init; }
public int Footers { get; init; }
public bool HasPageFields { get; init; }
public bool HasNumPagesFields { get; init; }
public bool HasSectionPagesFields { get; init; }
public IReadOnlyList<DocumentFieldInfo> Fields { get; init; } = [];
public int Bookmarks { get; init; }
public int ContentControls { get; init; }
public int Comments { get; init; }
public bool HasTrackedChanges { get; init; }
public bool HasProtectedParts { get; init; }
public IReadOnlyList<DocumentStyleInfo> StyleDetails { get; init; } = [];
public IReadOnlyList<SectionPartAssignment> SectionAssignments { get; init; } = [];
public InsertionPointInfo InsertionPoint { get; init; } = new() { Type = "None" };
public BodyImportPlan? BodyImportPlan { get; init; }
public IReadOnlyList<InspectionWarning> Warnings { get; init; } = [];
public IReadOnlyList<string> Styles => StyleDetails.Select(x => x.StyleId).ToArray();
}
public sealed record WordDocumentBuildRequest
{
public required string TemplatePath { get; init; }
public required string BodyPath { get; init; }
public required string OutputDocxPath { get; init; }
public string? RulesProfilePath { get; init; }
public bool PreserveBodyFormatting { get; init; } = true;
public bool PreservePageBreaks { get; init; } = true;
}
public sealed record WordDocumentResult
{
public required string OutputDocxPath { get; init; }
public string? OutputPdfPath { get; init; }
public required string LogPath { get; init; }
public required TimeSpan Duration { get; init; }
public required string InsertionPoint { get; init; }
public BodyImportResult? BodyImport { get; init; }
}
public sealed record WordPreviewResult
{
public required string PdfPath { get; init; }
public required TimeSpan Duration { get; init; }
}
public enum RuleEvaluationStatus { Pass, Fail, NotApplicable, NeedsReview, Error }
public sealed record RuleEvaluationResult
{
public required string RuleId { get; init; }
public required RuleEvaluationStatus Status { get; init; }
public required string Message { get; init; }
public string? ClauseId { get; init; }
public int? SourcePage { get; init; }
public string? DocumentLocation { get; init; }
public bool CanAutoFix { get; init; }
}
public sealed record OllamaSettings
{
public string BaseUrl { get; init; } = "http://192.168.200.112:11434";
public string Model { get; init; } = "";
public int TimeoutSeconds { get; init; } = 120;
}
@@ -0,0 +1,153 @@
namespace DocumentAutomation.Shared;
public enum TemplateSlotKind { PlainText, RichText, MainBody, Table, RepeatingTableRows, Image, Signature, Date, DocumentField }
public enum TemplateRegionKind { Fixed, Variable, Repeating, Ignored }
public sealed record TemplateSlotOccurrence
{
public required string Location { get; init; }
public int? Page { get; init; }
public string? Story { get; init; }
public string? XPath { get; init; }
}
public sealed record TemplateSlot
{
public required string Id { get; init; }
public required string Name { get; init; }
public required TemplateSlotKind Kind { get; init; }
public required string ContentControlTag { get; init; }
public bool IsRequired { get; init; }
public bool IsRepeating { get; init; }
public string? PageArchetypeId { get; init; }
public string? RepeatGroup { get; init; }
public required IReadOnlyList<TemplateSlotOccurrence> Occurrences { get; init; }
}
public sealed record FixedRegion
{
public required string Id { get; init; }
public required string Name { get; init; }
public required TemplateRegionKind Kind { get; init; }
public required IReadOnlyList<int> Pages { get; init; }
public required string Fingerprint { get; init; }
public string? Story { get; init; }
public string? Location { get; init; }
public double Confidence { get; init; }
}
public sealed record RepeatingRegion
{
public required string Id { get; init; }
public required string Name { get; init; }
public required IReadOnlyList<int> Pages { get; init; }
public required string Fingerprint { get; init; }
public required TemplateRegionKind Kind { get; init; }
public string? Location { get; init; }
}
public sealed record PageArchetype
{
public required string Id { get; init; }
public required string Name { get; init; }
public bool IsFirstPage { get; init; }
public bool IsRepeating { get; init; }
public required IReadOnlyList<int> ExamplePages { get; init; }
public required IReadOnlyList<string> FixedRegionIds { get; init; }
public required IReadOnlyList<string> SlotIds { get; init; }
}
public sealed record TemplateFieldBinding
{
public required string SourceField { get; init; }
public required string TargetSlot { get; init; }
public required string Scope { get; init; }
public string? SourceStyle { get; init; }
public string? TargetStyle { get; init; }
}
public sealed record SourceDocumentStructure
{
public required string SourcePath { get; init; }
public required string Sha256 { get; init; }
public required string NormalizedPath { get; init; }
public required string NormalizedSha256 { get; init; }
public required int PageCount { get; init; }
public required int SectionCount { get; init; }
public required IReadOnlyList<string> Stories { get; init; }
public required IReadOnlyList<string> RepeatedFingerprints { get; init; }
public required DocumentInspectionResult Inspection { get; init; }
}
public sealed record TemplateLearningSuggestion
{
public required string SuggestionType { get; init; }
public required string SourceLocation { get; init; }
public string? TargetSlot { get; init; }
public string? ProposedName { get; init; }
public double Confidence { get; init; }
public required string Reason { get; init; }
public bool RequiresConfirmation { get; init; } = true;
}
public sealed record LearnedTemplate
{
public required string TemplateId { get; init; }
public required string Name { get; init; }
public required string SourceFileName { get; init; }
public required string SourceSha256 { get; init; }
public required string NormalizedSha256 { get; init; }
public required string CreatedAt { get; init; }
public string MinimumEngineVersion { get; init; } = "1.0";
public required SourceDocumentStructure Source { get; init; }
public required IReadOnlyList<PageArchetype> PageArchetypes { get; init; }
public required IReadOnlyList<FixedRegion> FixedRegions { get; init; }
public required IReadOnlyList<RepeatingRegion> RepeatingRegions { get; init; }
public required IReadOnlyList<TemplateSlot> Slots { get; init; }
public required IReadOnlyList<TemplateFieldBinding> FieldBindings { get; init; }
public required IReadOnlyList<TemplateLearningSuggestion> Suggestions { get; init; }
}
public sealed record DocumentNormalizationRequest
{
public required string SourcePath { get; init; }
public required string WorkingDirectory { get; init; }
public bool OpenReadOnly { get; init; } = true;
public bool DisableMacros { get; init; } = true;
}
public sealed record DocumentNormalizationResult
{
public required string SourcePath { get; init; }
public required string NormalizedPath { get; init; }
public required string SourceSha256 { get; init; }
public required string NormalizedSha256 { get; init; }
public required bool WasConverted { get; init; }
public required TimeSpan Duration { get; init; }
public int? PageCount { get; init; }
}
public sealed record TemplateLearningRequest
{
public required string SourcePath { get; init; }
public required string TemplateName { get; init; }
public required string OutputPackagePath { get; init; }
public bool CreateDefaultSlots { get; init; } = true;
}
public sealed record TemplateGenerationRequest
{
public required string TemplatePackagePath { get; init; }
public required string ContentSourcePath { get; init; }
public required string OutputDocxPath { get; init; }
public IReadOnlyDictionary<string, string> ScalarValues { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed record TemplateGenerationResult
{
public required string OutputDocxPath { get; init; }
public required string OutputPdfPath { get; init; }
public required string GenerationLogPath { get; init; }
public required string ValidationLogPath { get; init; }
public required IReadOnlyList<RuleEvaluationResult> Validation { get; init; }
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
<ProjectReference Include="..\DocxWorkbench.OpenXml\DocxWorkbench.OpenXml.csproj" />
<ProjectReference Include="..\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,71 @@
using System.Diagnostics;
using System.Text.Json;
using DocumentAutomation.Shared;
using DocxWorkbench.Core;
using DocxWorkbench.OpenXml;
using DocxWorkbench.WordInterop;
namespace DocxWorkbench.App;
public sealed class MainForm : Form
{
private readonly ComboBox _mode = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 230 };
private readonly TextBox _template = new() { Width = 560 };
private readonly TextBox _body = new() { Width = 560 };
private readonly TextBox _package = new() { Width = 560 };
private readonly TextBox _templateName = new() { Width = 560, Text = "Learned document template" };
private readonly TextBox _output = new() { Width = 560 };
private readonly RichTextBox _structure = new() { Dock = DockStyle.Fill, ReadOnly = true };
private readonly RichTextBox _preview = new() { Dock = DockStyle.Fill, ReadOnly = true };
private readonly TextBox _log = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, ReadOnly = true };
private readonly TextBox _technicalError = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, ReadOnly = true };
private readonly Label _status = new() { AutoSize = true, Text = "Ready" };
private readonly Button _analyze; private readonly Button _learn; private readonly Button _generate; private readonly Button _build; private readonly Button _validate; private readonly Button _openDocx; private readonly Button _openPdf;
private readonly WorkbenchStateController _state = new();
private readonly IDocumentInspector _docx = new OpenXmlDocumentInspector(); private readonly IDocumentInspector _txt = new TextDocumentInspector(); private readonly IWordDocumentService _word = new WordDocumentService();
private DocumentInspectionResult? _templateResult; private DocumentInspectionResult? _bodyResult; private string? _builtDocx; private string? _builtPdf;
public MainForm()
{
Text = "DOCX Workbench"; Width = 1200; Height = 780; StartPosition = FormStartPosition.CenterScreen;
_mode.Items.AddRange(["Template learning", "Document generation", "Quick DOC_BODY"]); _mode.SelectedIndex = 2; _mode.SelectedIndexChanged += (_, _) => RefreshButtons();
var grid = new TableLayoutPanel { Dock = DockStyle.Top, Height = 245, ColumnCount = 3, RowCount = 7, Padding = new Padding(8) }; grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 155)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110));
AddControlRow(grid, 0, "Mode", _mode, null); AddControlRow(grid, 1, "Template DOC/DOCX", _template, BrowseTemplate); AddControlRow(grid, 2, "Template name", _templateName, null); AddControlRow(grid, 3, "Content DOC/DOCX/TXT", _body, BrowseBody); AddControlRow(grid, 4, "Package .dtemplate", _package, BrowsePackage); AddControlRow(grid, 5, "Output DOCX", _output, BrowseOutput);
var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; _analyze = Button("Analyze", async (_, _) => await AnalyzeAsync()); _learn = Button("Learn template", async (_, _) => await LearnAsync()); _generate = Button("Generate", async (_, _) => await GenerateAsync()); _build = Button("Quick build", async (_, _) => await QuickBuildAsync()); _validate = Button("Validate", async (_, _) => await ValidateAsync()); _openDocx = Button("Open DOCX", (_, _) => Open(_builtDocx)); _openPdf = Button("Open PDF", (_, _) => Open(_builtPdf)); buttons.Controls.AddRange([_analyze, _learn, _generate, _build, _validate, _openDocx, _openPdf]); grid.Controls.Add(buttons, 1, 6); grid.SetColumnSpan(buttons, 2);
var tabs = new TabControl { Dock = DockStyle.Fill }; AddTab(tabs, "Structure", _structure); AddTab(tabs, "Preview", _preview); AddTab(tabs, "Log", _log); AddTab(tabs, "Technical error", _technicalError); Controls.Add(tabs); Controls.Add(grid); _status.Dock = DockStyle.Bottom; Controls.Add(_status); foreach (var box in new[] { _template, _body, _package, _output }) box.TextChanged += (_, _) => InputsChanged(); FormClosed += (_, _) => (_word as IDisposable)?.Dispose(); RefreshButtons();
}
private static void AddTab(TabControl tabs, string title, Control content) { var tab = new TabPage(title); tab.Controls.Add(content); tabs.TabPages.Add(tab); }
private static void AddControlRow(TableLayoutPanel panel, int row, string title, Control control, EventHandler? browse) { panel.Controls.Add(new Label { Text = title, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row); panel.Controls.Add(control, 1, row); if (browse is not null) { var button = new Button { Text = "Browse...", Width = 95 }; button.Click += browse; panel.Controls.Add(button, 2, row); } }
private static Button Button(string text, EventHandler handler) { var button = new Button { Text = text, AutoSize = true }; button.Click += handler; return button; }
private void BrowseTemplate(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "Word document|*.doc;*.docx" }; if (dialog.ShowDialog() == DialogResult.OK) _template.Text = dialog.FileName; }
private void BrowseBody(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "Word or text|*.doc;*.docx;*.txt" }; if (dialog.ShowDialog() == DialogResult.OK) _body.Text = dialog.FileName; }
private void BrowsePackage(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "Learned template|*.dtemplate" }; if (dialog.ShowDialog() == DialogResult.OK) _package.Text = dialog.FileName; }
private void BrowseOutput(object? sender, EventArgs e) { using var dialog = new SaveFileDialog { Filter = "DOCX|*.docx", DefaultExt = "docx" }; if (dialog.ShowDialog() == DialogResult.OK) _output.Text = dialog.FileName; }
private async Task LearnAsync()
{
try { _status.Text = "Learning template..."; using var normalizer = new WordDocumentNormalizer(); var service = new TemplateLearningService(normalizer, new TemplatePackageStore(), _docx); var learned = await service.LearnAsync(new TemplateLearningRequest { SourcePath = _template.Text, TemplateName = _templateName.Text, OutputPackagePath = _package.Text }, null, CancellationToken.None); _structure.Text = JsonSerializer.Serialize(learned, new JsonSerializerOptions { WriteIndented = true }); AppendLog($"Template package created: {_package.Text}"); _status.Text = "Template learned"; } catch (Exception ex) { ShowError(ex, "Template learning failed"); }
}
private async Task GenerateAsync()
{
try { _status.Text = "Generating..."; using var normalizer = new WordDocumentNormalizer(); using var generator = new TemplateDocumentGenerator(new TemplatePackageStore(), normalizer, _docx); var result = await generator.GenerateAsync(new TemplateGenerationRequest { TemplatePackagePath = _package.Text, ContentSourcePath = _body.Text, OutputDocxPath = _output.Text }, null, CancellationToken.None); _builtDocx = result.OutputDocxPath; _builtPdf = result.OutputPdfPath; _preview.Text = JsonSerializer.Serialize(result.Validation, new JsonSerializerOptions { WriteIndented = true }); AppendLog(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Generation succeeded"; } catch (Exception ex) { ShowError(ex, "Generation failed"); }
}
private async Task AnalyzeAsync()
{
_status.Text = "Analyzing..."; var template = await InspectPathAsync(_template.Text); var body = await InspectPathAsync(_body.Text); _templateResult = template.Result; _bodyResult = body.Result; if (_templateResult is not null) ShowStructure(_templateResult); if (_bodyResult is not null) _preview.Text = _body.Text.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? File.ReadAllText(_body.Text) : JsonSerializer.Serialize(_bodyResult, new JsonSerializerOptions { WriteIndented = true }); LogInspection("Template", template); LogInspection("Body", body); _state.InputsSelected(template.Result is not null && body.Result is not null); _state.AnalysisCompleted(template.Result is not null, body.Result is not null, template.Result?.InsertionPoint.Type != "None"); _status.Text = _state.State == WorkbenchState.ReadyToBuild ? "Ready" : "Analysis completed with warnings"; RefreshButtons();
}
private Task<(DocumentInspectionResult? Result, Exception? Error, string Inspector)> InspectPathAsync(string path) => Task.Run(() => { try { if (path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) return (_docx.Inspect(path), (Exception?)null, "OpenXmlDocumentInspector"); if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) return (_txt.Inspect(path), (Exception?)null, "TextDocumentInspector"); throw new UnsupportedDocumentFormatException($"Unsupported format {Path.GetExtension(path)}. Use DOC/DOCX/TXT or normalize DOC first."); } catch (Exception ex) { return ((DocumentInspectionResult?)null, ex, "format-router"); } });
private async Task QuickBuildAsync() { if (!_state.CanBuild) { await AnalyzeAsync(); if (!_state.CanBuild) return; } _state.BeginBuild(); RefreshButtons(); try { var result = await _word.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = _template.Text, BodyPath = _body.Text, OutputDocxPath = _output.Text }, null, CancellationToken.None); var pdf = await _word.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); _builtDocx = result.OutputDocxPath; _builtPdf = pdf.PdfPath; _state.BuildSucceeded(); AppendLog(JsonSerializer.Serialize(new { result, pdf }, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Quick build succeeded"; } catch (Exception ex) { _state.BuildFailed(); ShowError(ex, "Build failed"); } finally { RefreshButtons(); } }
private async Task ValidateAsync() { if (!_state.CanValidate || _builtDocx is null || !File.Exists(_builtDocx)) return; try { var result = await Task.Run(() => _docx.Inspect(_builtDocx)); AppendLog(JsonSerializer.Serialize(new { label = "Output", result }, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Validation succeeded"; } catch (Exception ex) { ShowError(ex, "Validation failed"); } }
private void ShowStructure(DocumentInspectionResult result) => _structure.Text = JsonSerializer.Serialize(new { result.FilePath, result.Sections, result.Paragraphs, result.Tables, result.Headers, result.Footers, result.Fields, result.ImageSummary, result.SectionAssignments, result.StyleDetails, result.Warnings }, new JsonSerializerOptions { WriteIndented = true });
private void LogInspection(string label, (DocumentInspectionResult? Result, Exception? Error, string Inspector) value) { AppendLog(JsonSerializer.Serialize(new { label, inspector = value.Inspector, result = value.Result, error = value.Error?.Message }, new JsonSerializerOptions { WriteIndented = true })); if (value.Error is not null) _technicalError.Text = value.Error.Message; }
private void InputsChanged() { _builtDocx = null; _builtPdf = null; RefreshButtons(); }
private void RefreshButtons() { var mode = _mode.SelectedIndex; var templateSupported = _template.Text.EndsWith(".doc", StringComparison.OrdinalIgnoreCase) || _template.Text.EndsWith(".docx", StringComparison.OrdinalIgnoreCase); _analyze.Enabled = mode != 1 && File.Exists(_template.Text) && File.Exists(_body.Text); _learn.Enabled = mode == 0 && templateSupported && File.Exists(_template.Text) && !string.IsNullOrWhiteSpace(_package.Text); _generate.Enabled = mode == 1 && File.Exists(_package.Text) && File.Exists(_body.Text) && !string.IsNullOrWhiteSpace(_output.Text); _build.Enabled = mode == 2 && _state.CanBuild; _validate.Enabled = _state.CanValidate && _builtDocx is not null && File.Exists(_builtDocx); _openDocx.Enabled = _builtDocx is not null && File.Exists(_builtDocx); _openPdf.Enabled = _builtPdf is not null && File.Exists(_builtPdf); }
private void ShowError(Exception ex, string title) { _technicalError.Text = ex.ToString(); AppendLog(title + ": " + ex.Message); _status.Text = title; MessageBox.Show(ex.Message, title, MessageBoxButtons.OK, MessageBoxIcon.Error); }
private void AppendLog(string text) => _log.AppendText(text + Environment.NewLine);
private static void Open(string? path) { if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
}
@@ -0,0 +1,16 @@
namespace DocxWorkbench.App;
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
@@ -0,0 +1,73 @@
using DocumentAutomation.Shared;
namespace DocxWorkbench.Core;
public interface IDocumentInspector
{
DocumentInspectionResult Inspect(string docxPath);
}
public sealed class UnsupportedDocumentFormatException(string message) : InvalidOperationException(message);
public enum WorkbenchState { Empty, InputsSelected, Analyzed, ReadyToBuild, Building, BuildSucceeded, BuildFailed, Validating, ValidationSucceeded, ValidationFailed }
public sealed class WorkbenchStateController
{
public WorkbenchState State { get; private set; } = WorkbenchState.Empty;
public bool CanBuild => State == WorkbenchState.ReadyToBuild;
public bool CanValidate => State == WorkbenchState.BuildSucceeded;
public bool CanOpenDocx { get; private set; }
public bool CanOpenPdf { get; private set; }
public void InputsSelected(bool selected) { State = selected ? WorkbenchState.InputsSelected : WorkbenchState.Empty; if (!selected) ClearArtifacts(); }
public void AnalysisCompleted(bool templateOk, bool bodyOk, bool insertionPointFound) { State = templateOk && bodyOk && insertionPointFound ? WorkbenchState.ReadyToBuild : WorkbenchState.Analyzed; ClearArtifacts(); }
public void BeginBuild() { if (!CanBuild) throw new InvalidOperationException("Build is unavailable in the current state."); State = WorkbenchState.Building; ClearArtifacts(); }
public void BuildSucceeded() { State = WorkbenchState.BuildSucceeded; CanOpenDocx = true; CanOpenPdf = true; }
public void BuildFailed() { State = WorkbenchState.BuildFailed; ClearArtifacts(); }
public void BeginValidation() { if (!CanValidate) throw new InvalidOperationException("Validation is available only after a successful build."); State = WorkbenchState.Validating; }
public void ValidationCompleted(bool success) => State = success ? WorkbenchState.ValidationSucceeded : WorkbenchState.ValidationFailed;
private void ClearArtifacts() { CanOpenDocx = false; CanOpenPdf = false; }
}
public interface IWordDocumentService
{
Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken);
Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken);
}
public interface IDocxBodyImporter
{
Task<BodyImportResult> ImportAsync(DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken);
}
public interface IWordDocumentNormalizer
{
Task<DocumentNormalizationResult> NormalizeAsync(DocumentNormalizationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken);
}
public interface ITemplateLearningService
{
Task<LearnedTemplate> LearnAsync(TemplateLearningRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken);
}
public interface ITemplatePackageStore
{
void Save(string packagePath, LearnedTemplate template, string normalizedDocxPath);
(LearnedTemplate Template, string TemplateDocxPath, string WorkingDirectory) Open(string packagePath);
}
public interface ITemplateDocumentGenerator
{
Task<TemplateGenerationResult> GenerateAsync(TemplateGenerationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken);
}
public static class DocumentInputValidation
{
public static void Validate(WordDocumentBuildRequest request)
{
if (!File.Exists(request.TemplatePath)) throw new FileNotFoundException("Шаблон DOCX не найден.", request.TemplatePath);
if (!File.Exists(request.BodyPath)) throw new FileNotFoundException("Файл тела документа не найден.", request.BodyPath);
if (!request.TemplatePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Шаблон должен быть DOCX.");
if (Path.GetFullPath(request.TemplatePath).Equals(Path.GetFullPath(request.OutputDocxPath), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Нельзя перезаписывать исходный шаблон.");
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(request.OutputDocxPath))!);
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,77 @@
using System.Text.RegularExpressions;
using DocumentAutomation.Shared;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocxWorkbench.Core;
namespace DocxWorkbench.OpenXml;
public sealed class OpenXmlDocumentInspector : IDocumentInspector
{
private static readonly string[] KnownFields = ["PAGE", "NUMPAGES", "SECTIONPAGES", "FILENAME"];
public DocumentInspectionResult Inspect(string docxPath)
{
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath);
if (!docxPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Open XML inspector accepts DOCX only, received {Path.GetExtension(docxPath)}.");
var info = new FileInfo(docxPath); using var package = WordprocessingDocument.Open(docxPath, false); var main = package.MainDocumentPart ?? throw new InvalidDataException("The DOCX has no main document part."); var roots = GetRoots(main); var fields = roots.SelectMany(x => FindFields(x.Root, x.Part, x.Section)).ToArray(); var images = CountImages(main, roots);
return new DocumentInspectionResult
{
FilePath = docxPath, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, Sections = Math.Max(1, main.Document.Descendants<SectionProperties>().Count()), Paragraphs = main.Document.Descendants<Paragraph>().Count(), Tables = main.Document.Descendants<Table>().Count(), Images = images.RasterImages, ImageSummary = images,
Headers = main.HeaderParts.Count(), Footers = main.FooterParts.Count(), HasPageFields = fields.Any(x => x.Type == "PAGE"), HasNumPagesFields = fields.Any(x => x.Type == "NUMPAGES"), HasSectionPagesFields = fields.Any(x => x.Type == "SECTIONPAGES"), Fields = fields,
Bookmarks = main.Document.Descendants<BookmarkStart>().Count(), ContentControls = main.Document.Descendants<SdtElement>().Count(), Comments = main.GetPartsOfType<WordprocessingCommentsPart>().Count(), HasTrackedChanges = main.Document.Descendants<Inserted>().Any() || main.Document.Descendants<Deleted>().Any(), HasProtectedParts = main.Document.Descendants<DocumentProtection>().Any(),
StyleDetails = GetStyles(main), SectionAssignments = GetSectionAssignments(main), InsertionPoint = FindInsertionPoint(main.Document), BodyImportPlan = CreateBodyImportPlan(main), Warnings = FindWarnings(roots)
};
}
private static IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> GetRoots(MainDocumentPart main)
{
var roots = new List<(OpenXmlPartRootElement, string, int?)> { (main.Document, "MainDocumentPart", null) }; roots.AddRange(main.HeaderParts.Select((p, i) => ((OpenXmlPartRootElement)p.Header, $"HeaderPart[{i + 1}]", (int?)null))); roots.AddRange(main.FooterParts.Select((p, i) => ((OpenXmlPartRootElement)p.Footer, $"FooterPart[{i + 1}]", (int?)null))); if (main.DocumentSettingsPart?.Settings is not null) roots.Add((main.DocumentSettingsPart.Settings, "SettingsPart", null)); if (main.FootnotesPart?.Footnotes is not null) roots.Add((main.FootnotesPart.Footnotes, "FootnotesPart", null)); if (main.EndnotesPart?.Endnotes is not null) roots.Add((main.EndnotesPart.Endnotes, "EndnotesPart", null)); foreach (var comments in main.GetPartsOfType<WordprocessingCommentsPart>()) if (comments.Comments is not null) roots.Add((comments.Comments, "CommentsPart", null)); return roots;
}
private static IEnumerable<DocumentFieldInfo> FindFields(OpenXmlElement root, string part, int? section)
{
foreach (var simple in root.Descendants<SimpleField>()) { var type = NormalizeField(simple.Instruction?.Value); if (type is not null) yield return new DocumentFieldInfo { Type = type, Part = part, Section = section, Instruction = simple.Instruction?.Value }; }
var active = new List<string>(); foreach (var element in root.Descendants()) { if (element is FieldChar begin && begin.FieldCharType?.Value == FieldCharValues.Begin) active.Add(""); else if (element is FieldCode code && active.Count > 0) active[^1] += " " + code.Text; else if (element is FieldChar end && end.FieldCharType?.Value == FieldCharValues.End && active.Count > 0) { var raw = active[^1]; active.RemoveAt(active.Count - 1); var type = NormalizeField(raw); if (type is not null) yield return new DocumentFieldInfo { Type = type, Part = part, Section = section, Instruction = raw.Trim() }; } }
}
private static string? NormalizeField(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var normalized = Regex.Replace(value, @"\s+", " ").Trim(); var name = Regex.Match(normalized, @"(?i)^(PAGE|NUMPAGES|SECTIONPAGES|FILENAME)\b").Value.ToUpperInvariant(); return KnownFields.Contains(name) ? name : null; }
private static DocumentImageSummary CountImages(MainDocumentPart main, IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> roots)
{
var imageParts = new HashSet<OpenXmlPart>(); var drawings = roots.Sum(x => x.Root.Descendants<Drawing>().Count()); var shapes = roots.Sum(x => x.Root.Descendants<DocumentFormat.OpenXml.Vml.Shape>().Count()); var external = 0;
foreach (var image in main.GetPartsOfType<ImagePart>()) imageParts.Add(image); foreach (var header in main.HeaderParts) { foreach (var image in header.GetPartsOfType<ImagePart>()) imageParts.Add(image); external += header.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); } foreach (var footer in main.FooterParts) { foreach (var image in footer.GetPartsOfType<ImagePart>()) imageParts.Add(image); external += footer.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); }
external += main.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); return new DocumentImageSummary { RasterImages = imageParts.Count, DrawingObjects = drawings, VmlShapes = shapes, ExternalImages = external };
}
private static IReadOnlyList<DocumentStyleInfo> GetStyles(MainDocumentPart main)
{
var usage = main.Document.Descendants<ParagraphStyleId>().GroupBy(x => x.Val?.Value ?? "").ToDictionary(x => x.Key, x => x.Count()); return main.StyleDefinitionsPart?.Styles?.Elements<Style>().Select(style => new DocumentStyleInfo { StyleId = style.StyleId?.Value ?? "", Name = style.StyleName?.Val?.Value, Type = style.Type is null ? null : style.Type.Value.ToString(), IsDefault = style.Default?.Value == true, BasedOn = style.BasedOn?.Val?.Value, UsageCount = usage.GetValueOrDefault(style.StyleId?.Value ?? "") }).Where(x => x.StyleId.Length > 0).ToArray() ?? [];
}
private static IReadOnlyList<SectionPartAssignment> GetSectionAssignments(MainDocumentPart main)
{
var result = new List<SectionPartAssignment>(); var sections = main.Document.Body?.Elements<SectionProperties>().ToArray() ?? []; for (var i = 0; i < sections.Length; i++) { var section = sections[i]; var headers = section.Elements<HeaderReference>().Select(x => $"{x.Type?.Value}:{PartName(main, x.Id?.Value)}").ToArray(); var footers = section.Elements<FooterReference>().Select(x => $"{x.Type?.Value}:{PartName(main, x.Id?.Value)}").ToArray(); result.Add(new SectionPartAssignment { Section = i + 1, Headers = headers, Footers = footers, LinkToPrevious = i > 0 && headers.Length == 0 && footers.Length == 0 }); } return result;
}
private static string PartName(MainDocumentPart main, string? id) { if (id is null) return "missing"; try { return main.GetPartById(id).Uri.ToString(); } catch { return "unresolved:" + id; } }
private static InsertionPointInfo FindInsertionPoint(Document root) { if (root.Descendants<SdtElement>().Any(x => string.Equals(x.SdtProperties?.GetFirstChild<Tag>()?.Val?.Value, "DOC_BODY", StringComparison.OrdinalIgnoreCase))) return new InsertionPointInfo { Type = "ContentControl", Location = "MainDocumentPart" }; if (root.Descendants<BookmarkStart>().Any(x => string.Equals(x.Name?.Value, "DOC_BODY", StringComparison.OrdinalIgnoreCase))) return new InsertionPointInfo { Type = "Bookmark", Location = "MainDocumentPart" }; if (root.InnerText.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return new InsertionPointInfo { Type = "TextMarker", Location = "MainDocumentPart" }; return new InsertionPointInfo { Type = "None" }; }
private static IReadOnlyList<InspectionWarning> FindWarnings(IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> roots) { var result = new List<InspectionWarning>(); foreach (var root in roots) foreach (Match match in Regex.Matches(root.Root.InnerText, @"(?i)\b(2019|legacy|старый)\b")) result.Add(new InspectionWarning { Code = "PossibleLegacyProjectData", Message = "A value matched the legacy-project heuristic.", Part = root.Part, Location = "text", MatchedValue = match.Value, DetectionRule = "word-boundary: 2019|legacy|старый" }); return result; }
private static BodyImportPlan CreateBodyImportPlan(MainDocumentPart main)
{
var body = main.Document.Body; if (body is null) return new BodyImportPlan { ParagraphCount = 0, TableCount = 0, InlineShapeCount = 0, FloatingShapeCount = 0, InternalSectionBreakCount = 0, PageBreakCount = 0, IncludedStories = ["wdMainTextStory"], ExcludedStories = ["wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFirstPageFooterStory", "wdEvenPagesFooterStory"], Warnings = [] };
var drawings = body.Descendants<Drawing>(); return new BodyImportPlan { ParagraphCount = body.Descendants<Paragraph>().Count(), TableCount = body.Descendants<Table>().Count(), InlineShapeCount = drawings.Count(x => x.Ancestors<DocumentFormat.OpenXml.Drawing.Wordprocessing.Inline>().Any()), FloatingShapeCount = drawings.Count(x => x.Ancestors<DocumentFormat.OpenXml.Drawing.Wordprocessing.Anchor>().Any()), InternalSectionBreakCount = Math.Max(0, body.Descendants<SectionProperties>().Count() - 1), PageBreakCount = body.Descendants<Break>().Count(x => x.Type?.Value == BreakValues.Page), IncludedStories = ["wdMainTextStory"], ExcludedStories = ["wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFirstPageFooterStory", "wdEvenPagesFooterStory"], Warnings = ["Source headers, footers, page setup and final section properties are excluded."] };
}
}
public sealed class TextDocumentInspector : IDocumentInspector
{
public DocumentInspectionResult Inspect(string path) { if (!File.Exists(path)) throw new FileNotFoundException("TXT was not found.", path); if (!path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"TXT inspector accepts TXT only, received {Path.GetExtension(path)}."); var info = new FileInfo(path); return new DocumentInspectionResult { FilePath = path, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, Sections = 1, Paragraphs = File.ReadAllLines(path).Length, InsertionPoint = new InsertionPointInfo { Type = "None" } }; }
}
public sealed class DocumentInspector : IDocumentInspector
{
private readonly OpenXmlDocumentInspector _inner = new();
public DocumentInspectionResult Inspect(string docxPath) => _inner.Inspect(docxPath);
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using DocumentAutomation.Shared;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocxWorkbench.Core;
namespace DocxWorkbench.OpenXml;
public sealed class TemplatePackageStore : ITemplatePackageStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public void Save(string packagePath, LearnedTemplate template, string normalizedDocxPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(packagePath))!);
if (File.Exists(packagePath)) File.Delete(packagePath);
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
WriteJson(archive, "manifest.json", new { schemaVersion = "1.0", templateId = template.TemplateId, name = template.Name, sourceFileName = template.SourceFileName, sourceSha256 = template.SourceSha256, normalizedSha256 = template.NormalizedSha256, createdAt = template.CreatedAt, minimumEngineVersion = template.MinimumEngineVersion });
WriteJson(archive, "source-metadata.json", template.Source);
WriteJson(archive, "page-archetypes.json", template.PageArchetypes);
WriteJson(archive, "fixed-regions.json", template.FixedRegions.Concat(template.RepeatingRegions.Select(x => new FixedRegion { Id = x.Id, Name = x.Name, Kind = x.Kind, Pages = x.Pages, Fingerprint = x.Fingerprint, Location = x.Location })).ToArray());
WriteJson(archive, "slots.json", template.Slots);
WriteJson(archive, "field-bindings.json", template.FieldBindings);
WriteJson(archive, "mapping-rules.json", template.Suggestions);
WriteJson(archive, "validation-profile.json", new { schemaVersion = "1.0", requiredSlots = template.Slots.Where(x => x.IsRequired).Select(x => x.ContentControlTag).ToArray(), allowTrackedChanges = false, allowExternalLinks = false });
var entry = archive.CreateEntry("template.docx", CompressionLevel.Optimal); using var input = File.OpenRead(normalizedDocxPath); using var output = entry.Open(); input.CopyTo(output);
}
public (LearnedTemplate Template, string TemplateDocxPath, string WorkingDirectory) Open(string packagePath)
{
if (!File.Exists(packagePath)) throw new FileNotFoundException("Template package was not found.", packagePath);
var work = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "template-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(work); ZipFile.ExtractToDirectory(packagePath, work);
var manifest = ReadJson<Manifest>(Path.Combine(work, "manifest.json")); if (manifest.SchemaVersion != "1.0") throw new InvalidDataException($"Unsupported template schema: {manifest.SchemaVersion}");
var template = new LearnedTemplate { TemplateId = manifest.TemplateId, Name = manifest.Name, SourceFileName = manifest.SourceFileName, SourceSha256 = manifest.SourceSha256, NormalizedSha256 = manifest.NormalizedSha256, CreatedAt = manifest.CreatedAt, MinimumEngineVersion = manifest.MinimumEngineVersion, Source = ReadJson<SourceDocumentStructure>(Path.Combine(work, "source-metadata.json")), PageArchetypes = ReadJson<List<PageArchetype>>(Path.Combine(work, "page-archetypes.json")), FixedRegions = ReadJson<List<FixedRegion>>(Path.Combine(work, "fixed-regions.json")).Where(x => x.Kind != TemplateRegionKind.Repeating).ToArray(), RepeatingRegions = ReadJson<List<FixedRegion>>(Path.Combine(work, "fixed-regions.json")).Where(x => x.Kind == TemplateRegionKind.Repeating).Select(x => new RepeatingRegion { Id = x.Id, Name = x.Name, Pages = x.Pages, Fingerprint = x.Fingerprint, Kind = x.Kind, Location = x.Location }).ToArray(), Slots = ReadJson<List<TemplateSlot>>(Path.Combine(work, "slots.json")), FieldBindings = ReadJson<List<TemplateFieldBinding>>(Path.Combine(work, "field-bindings.json")), Suggestions = ReadJson<List<TemplateLearningSuggestion>>(Path.Combine(work, "mapping-rules.json")) };
var docx = Path.Combine(work, "template.docx"); if (!File.Exists(docx)) throw new InvalidDataException("Template package does not contain template.docx."); return (template, docx, work);
}
private static void WriteJson<T>(ZipArchive archive, string name, T value) { var entry = archive.CreateEntry(name, CompressionLevel.Optimal); using var writer = new StreamWriter(entry.Open()); writer.Write(JsonSerializer.Serialize(value, JsonOptions)); }
private static T ReadJson<T>(string path) => JsonSerializer.Deserialize<T>(File.ReadAllText(path), JsonOptions) ?? throw new InvalidDataException($"Invalid template package entry: {Path.GetFileName(path)}");
private sealed record Manifest(string SchemaVersion, string TemplateId, string Name, string SourceFileName, string SourceSha256, string NormalizedSha256, string CreatedAt, string MinimumEngineVersion);
}
public sealed class TemplateLearningService : ITemplateLearningService
{
private readonly IWordDocumentNormalizer _normalizer;
private readonly ITemplatePackageStore _packages;
private readonly IDocumentInspector _inspector;
public TemplateLearningService(IWordDocumentNormalizer normalizer, ITemplatePackageStore packages, IDocumentInspector? inspector = null) { _normalizer = normalizer; _packages = packages; _inspector = inspector ?? new OpenXmlDocumentInspector(); }
public async Task<LearnedTemplate> LearnAsync(TemplateLearningRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
{
if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Template source was not found.", request.SourcePath);
var normalized = await _normalizer.NormalizeAsync(new DocumentNormalizationRequest { SourcePath = request.SourcePath, WorkingDirectory = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "learning-" + Guid.NewGuid().ToString("N")) }, progress, cancellationToken);
try
{
progress?.Report(new WordOperationProgress { Stage = "AnalyzeTemplate", Message = "Inspecting normalized template" });
EnsureBodyMarker(normalized.NormalizedPath); var normalizedHash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(normalized.NormalizedPath))); var inspection = _inspector.Inspect(normalized.NormalizedPath) with { FilePath = "template.docx" }; var pages = Math.Max(1, normalized.PageCount ?? inspection.Sections); var archetypes = CreateArchetypes(pages, inspection); var repeated = FindRepeatedRegions(inspection); var slots = request.CreateDefaultSlots ? DefaultSlots() : [];
var source = new SourceDocumentStructure { SourcePath = Path.GetFileName(request.SourcePath), Sha256 = normalized.SourceSha256, NormalizedPath = "template.docx", NormalizedSha256 = normalizedHash, PageCount = pages, SectionCount = inspection.Sections, Stories = ["wdMainTextStory", "wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFootnotesStory", "wdEndnotesStory", "wdCommentsStory", "wdTextFrameStory"], RepeatedFingerprints = repeated.Select(x => x.Fingerprint).ToArray(), Inspection = inspection };
var learned = new LearnedTemplate { TemplateId = Guid.NewGuid().ToString("D"), Name = request.TemplateName, SourceFileName = Path.GetFileName(request.SourcePath), SourceSha256 = normalized.SourceSha256, NormalizedSha256 = normalizedHash, CreatedAt = DateTimeOffset.Now.ToString("O"), Source = source, PageArchetypes = archetypes, FixedRegions = [], RepeatingRegions = repeated, Slots = slots, FieldBindings = [], Suggestions = BuildSuggestions(inspection, slots) };
_packages.Save(request.OutputPackagePath, learned, normalized.NormalizedPath); return learned;
}
finally { TryDeleteDirectory(normalized.NormalizedPath, normalized.SourcePath); }
}
private static void EnsureBodyMarker(string path) { using var doc = WordprocessingDocument.Open(path, true); var main = doc.MainDocumentPart ?? throw new InvalidDataException("Main document part is missing."); var body = main.Document.Body ?? throw new InvalidDataException("Document body is missing."); if (main.Document.InnerText.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return; body.AppendChild(new Paragraph(new Run(new Text("{{DOC_BODY}}")))); main.Document.Save(); }
private static IReadOnlyList<PageArchetype> CreateArchetypes(int pages, DocumentInspectionResult inspection) { var result = new List<PageArchetype> { new() { Id = "FirstPage", Name = "FirstPage", IsFirstPage = true, IsRepeating = false, ExamplePages = [1], FixedRegionIds = [], SlotIds = [] } }; if (pages > 1) result.Add(new PageArchetype { Id = "RepeatingPage", Name = "RepeatingPage", IsRepeating = true, ExamplePages = Enumerable.Range(2, pages - 1).ToArray(), FixedRegionIds = [], SlotIds = [] }); return result; }
private static IReadOnlyList<RepeatingRegion> FindRepeatedRegions(DocumentInspectionResult inspection) => inspection.SectionAssignments.GroupBy(x => string.Join("|", x.Headers.Concat(x.Footers))).Where(x => x.Count() > 1 && x.Key.Length > 0).Select((x, i) => new RepeatingRegion { Id = "region-" + (i + 1), Name = "Repeated header/footer", Pages = x.Select(y => y.Section).ToArray(), Fingerprint = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(x.Key))), Kind = TemplateRegionKind.Repeating, Location = "section assignments" }).ToArray();
private static IReadOnlyList<TemplateSlot> DefaultSlots() => new[] { "PROJECT.CODE", "PROJECT.NAME", "PROJECT.OBJECT", "DOCUMENT.CODE", "DOCUMENT.TITLE", "DOCUMENT.STAGE", "DOCUMENT.SHEET", "DOCUMENT.SHEET_COUNT", "REGISTER.CONTENTS", "REGISTER.MAIN_DRAWING_SETS", "REGISTER.DRAWINGS", "REGISTER.REFERENCE_DOCUMENTS", "DOCUMENT.GENERAL_NOTES", "DOCUMENT.MAIN_BODY" }.Select(x => new TemplateSlot { Id = x, Name = x, Kind = x == "DOCUMENT.MAIN_BODY" ? TemplateSlotKind.MainBody : TemplateSlotKind.PlainText, ContentControlTag = x, IsRequired = x is "DOCUMENT.CODE" or "DOCUMENT.TITLE" or "DOCUMENT.MAIN_BODY", Occurrences = [] }).ToArray();
private static IReadOnlyList<TemplateLearningSuggestion> BuildSuggestions(DocumentInspectionResult inspection, IReadOnlyList<TemplateSlot> slots) => [new TemplateLearningSuggestion { SuggestionType = "PageArchetype", SourceLocation = "document", ProposedName = inspection.Sections > 1 ? "FirstPage + RepeatingPage" : "SinglePage", Confidence = 0.5, Reason = "Initial deterministic proposal based on document sections." }];
private static void TryDeleteDirectory(string normalized, string source) { var directory = Path.GetDirectoryName(normalized); if (directory is not null && !Path.GetFullPath(source).Equals(Path.GetFullPath(normalized), StringComparison.OrdinalIgnoreCase)) try { if (Directory.Exists(directory)) Directory.Delete(directory, true); } catch { } }
}
@@ -0,0 +1,80 @@
using System.Runtime.InteropServices;
using System.IO.Compression;
using System.Text;
using System.Xml.Linq;
using DocumentAutomation.Shared;
using DocxWorkbench.Core;
namespace DocxWorkbench.WordInterop;
public sealed class DocxBodyImporter : IDocxBodyImporter, IDisposable
{
private readonly StaWordQueue _queue;
private readonly bool _ownsQueue;
public DocxBodyImporter() : this(new StaWordQueue(), true) { }
internal DocxBodyImporter(StaWordQueue queue) : this(queue, false) { }
private DocxBodyImporter(StaWordQueue queue, bool ownsQueue) { _queue = queue; _ownsQueue = ownsQueue; }
public Task<BodyImportResult> ImportAsync(DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
=> _queue.Enqueue(() => ImportStandalone(request, progress, cancellationToken), cancellationToken);
private BodyImportResult ImportStandalone(DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken token)
{
dynamic? application = null; var proxies = new List<object?>(); try { var type = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Activator.CreateInstance(type) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; return ImportOnSta(application, null, request.DestinationRangeHandle, request, progress, token, proxies); } finally { try { if (application is not null) application.Quit(0); } catch { } Release(application); ReleaseAll(proxies); }
}
internal BodyImportResult ImportOnSta(dynamic application, dynamic destinationDocument, dynamic destinationRange, DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken token, List<object?> proxies)
{
if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Source DOCX was not found.", request.SourcePath); if (!request.SourcePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException("DocxBodyImporter accepts DOCX only.");
var plan = BuildPlanFromOpenXml(request.SourcePath);
var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "body-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); var sourceCopy = Path.Combine(temp, "source.docx"); File.Copy(request.SourcePath, sourceCopy, true); NormalizeSourceSections(sourceCopy, request.SectionBreakPolicy); dynamic? sourceDocument = null; dynamic? sourceDocuments = null; dynamic? sourceRange = null; dynamic? importRange = null; var localProxies = new List<object?>();
try
{
token.ThrowIfCancellationRequested(); progress?.Report(new WordOperationProgress { Stage = "OpenSource", Message = "Opening source DOCX read-only" }); sourceDocuments = Own(application.Documents, localProxies); sourceDocument = Own(sourceDocuments.Open(sourceCopy, true, false, false), localProxies); dynamic stories = Own(sourceDocument.StoryRanges, localProxies); sourceRange = Own(stories[1], localProxies); var start = (int)sourceRange.Start; var end = (int)sourceRange.End; if (end > start) end -= 1; importRange = Own(sourceDocument.Range(start, end), localProxies);
var warnings = new List<string>(); var sourceRevisions = TryCount(importRange.Revisions); var sourceComments = TryCount(importRange.Comments); if (sourceRevisions > 0) { if (request.RevisionPolicy == RevisionImportPolicy.BlockImport) throw new WordAutomationException("Source DOCX contains tracked changes and RevisionPolicy is BlockImport."); if (request.RevisionPolicy == RevisionImportPolicy.AcceptAll) importRange.Revisions.AcceptAll(); if (request.RevisionPolicy == RevisionImportPolicy.RejectAll) importRange.Revisions.RejectAll(); if (request.RevisionPolicy == RevisionImportPolicy.PreserveAndWarn) warnings.Add($"Tracked changes preserved: {sourceRevisions}."); } if (sourceComments > 0) { if (request.CommentPolicy == CommentImportPolicy.BlockImport) throw new WordAutomationException("Source DOCX contains comments and CommentPolicy is BlockImport."); if (request.CommentPolicy == CommentImportPolicy.PreserveAndWarn) warnings.Add($"Comments preserved: {sourceComments}."); }
progress?.Report(new WordOperationProgress { Stage = "FormattedText", Message = "Copying only the main text story" }); destinationRange.FormattedText = importRange.FormattedText; if (request.CommentPolicy == CommentImportPolicy.Exclude) RemoveImportedComments(destinationRange); if (request.RevisionPolicy == RevisionImportPolicy.PreserveAndWarn) warnings.Add("Revision policy: PreserveAndWarn.");
plan = plan with { Warnings = warnings }; return new BodyImportResult { Plan = plan, InsertionMode = "FormattedText", Warnings = warnings };
}
finally { try { if (sourceDocument is not null) sourceDocument.Close(0); } catch { } ReleaseAll(localProxies); TryDelete(temp); }
}
private static BodyImportPlan BuildPlanFromOpenXml(string path)
{
using var archive = ZipFile.OpenRead(path);
var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("Source DOCX document.xml is missing.");
using var reader = new StreamReader(entry.Open());
var document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace);
var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
var wp = XNamespace.Get("http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing");
var body = document.Root?.Element(w + "body") ?? throw new InvalidDataException("Source DOCX body is missing.");
var sectionMarks = body.Descendants(w + "sectPr").ToList();
var internalSections = Math.Max(0, sectionMarks.Count - 1);
var pageBreaks = body.Descendants(w + "br").Count(x => string.Equals((string?)x.Attribute(w + "type"), "page", StringComparison.OrdinalIgnoreCase));
return new BodyImportPlan
{
ParagraphCount = body.Descendants(w + "p").Count(),
TableCount = body.Descendants(w + "tbl").Count(),
InlineShapeCount = body.Descendants(wp + "inline").Count(),
FloatingShapeCount = body.Descendants(wp + "anchor").Count(),
InternalSectionBreakCount = internalSections,
PageBreakCount = pageBreaks,
IncludedStories = ["wdMainTextStory"],
ExcludedStories = ["wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFirstPageFooterStory", "wdEvenPagesFooterStory"],
Warnings = []
};
}
private static void NormalizeSourceSections(string path, SectionBreakPolicy policy)
{
if (policy == SectionBreakPolicy.ConvertToParagraph) policy = SectionBreakPolicy.Remove;
using var archive = ZipFile.Open(path, ZipArchiveMode.Update); var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("Source DOCX document.xml is missing."); string xml; using (var reader = new StreamReader(entry.Open())) xml = reader.ReadToEnd(); var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main"); var document = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); var body = document.Root?.Element(w + "body") ?? throw new InvalidDataException("Source DOCX body is missing."); var sections = body.Descendants(w + "sectPr").ToList(); for (var i = 0; i < sections.Count; i++) { var section = sections[i]; var parent = section.Parent; var paragraph = parent?.Name == w + "pPr" ? parent.Parent : null; section.Remove(); if (i < sections.Count - 1 && policy == SectionBreakPolicy.ConvertToPageBreak && paragraph is not null) paragraph.Add(new XElement(w + "r", new XElement(w + "br", new XAttribute(w + "type", "page")))); } entry.Delete(); var replacement = archive.CreateEntry("word/document.xml", CompressionLevel.Optimal); using var stream = replacement.Open(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); document.Save(writer, SaveOptions.DisableFormatting);
}
private static void RemoveImportedComments(dynamic range) { try { dynamic comments = range.Comments; for (var i = comments.Count; i >= 1; i--) comments[i].Delete(); } catch { } }
private static int TryCount(dynamic collection) { try { return (int)collection.Count; } catch { return 0; } }
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
private static void Release(object? value) { try { if (value is not null && Marshal.IsComObject(value)) Marshal.FinalReleaseComObject(value); } catch { } }
private static void ReleaseAll(IEnumerable<object?> values) { foreach (var value in values.Reverse()) Release(value); }
private static void TryDelete(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } }
public void Dispose() { if (_ownsQueue) _queue.Dispose(); }
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("DocxWorkbench.Tests")]
@@ -0,0 +1,60 @@
using System.Collections.Concurrent;
namespace DocxWorkbench.WordInterop;
internal sealed class StaWordQueue : IDisposable
{
private readonly BlockingCollection<Action> _jobs = new();
private readonly Thread _thread;
private readonly ManualResetEventSlim _stopped = new(false);
private readonly object _lifetimeGate = new();
private bool _accepting = true;
public StaWordQueue()
{
_thread = new Thread(Run) { IsBackground = true, Name = "DocumentAutomation.Word.STA" };
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
}
public Task<T> Enqueue<T>(Func<T> operation, CancellationToken cancellationToken)
{
if (operation is null) throw new ArgumentNullException(nameof(operation));
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled<T>(cancellationToken);
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
lock (_lifetimeGate)
{
if (!_accepting) throw new ObjectDisposedException(nameof(StaWordQueue));
try { _jobs.Add(() => Execute(operation, completion, cancellationToken), cancellationToken); }
catch (InvalidOperationException) { throw new ObjectDisposedException(nameof(StaWordQueue)); }
catch (OperationCanceledException) { completion.TrySetCanceled(cancellationToken); }
}
return completion.Task;
}
private static void Execute<T>(Func<T> operation, TaskCompletionSource<T> completion, CancellationToken cancellationToken)
{
try { cancellationToken.ThrowIfCancellationRequested(); completion.TrySetResult(operation()); }
catch (OperationCanceledException ex) { completion.TrySetCanceled(ex.CancellationToken); }
catch (Exception ex) { completion.TrySetException(ex); }
}
private void Run()
{
try { foreach (var job in _jobs.GetConsumingEnumerable()) job(); }
finally { _stopped.Set(); }
}
public void Dispose()
{
lock (_lifetimeGate)
{
if (!_accepting) return;
_accepting = false;
_jobs.CompleteAdding();
}
if (Thread.CurrentThread != _thread) _stopped.Wait(TimeSpan.FromSeconds(30));
// Do not dispose the collection or stop event while the STA worker may
// still be draining it. The worker owns the final enumeration lifetime.
}
}
@@ -0,0 +1,49 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using DocumentAutomation.Shared;
using DocxWorkbench.Core;
namespace DocxWorkbench.WordInterop;
public sealed class TemplateDocumentGenerator : ITemplateDocumentGenerator, IDisposable
{
private readonly ITemplatePackageStore _packages;
private readonly IWordDocumentNormalizer _normalizer;
private readonly WordDocumentService _word = new();
private readonly IDocumentInspector _inspector;
public TemplateDocumentGenerator(ITemplatePackageStore packages, IWordDocumentNormalizer normalizer, IDocumentInspector inspector) { _packages = packages; _normalizer = normalizer; _inspector = inspector; }
public async Task<TemplateGenerationResult> GenerateAsync(TemplateGenerationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
{
var opened = _packages.Open(request.TemplatePackagePath); DocumentNormalizationResult? normalizedBody = null; var outputPdf = Path.ChangeExtension(request.OutputDocxPath, ".pdf");
try
{
var bodyPath = request.ContentSourcePath;
if (Path.GetExtension(bodyPath).Equals(".doc", StringComparison.OrdinalIgnoreCase)) { normalizedBody = await _normalizer.NormalizeAsync(new DocumentNormalizationRequest { SourcePath = bodyPath, WorkingDirectory = Path.Combine(opened.WorkingDirectory, "body") }, progress, cancellationToken); bodyPath = normalizedBody.NormalizedPath; }
var workingTemplate = Path.Combine(opened.WorkingDirectory, "working-template.docx"); File.Copy(opened.TemplateDocxPath, workingTemplate, true); ReplaceScalarTokens(workingTemplate, request.ScalarValues); progress?.Report(new WordOperationProgress { Stage = "Generate", Message = "Building document from learned template" });
var result = await _word.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = workingTemplate, BodyPath = bodyPath, OutputDocxPath = request.OutputDocxPath }, progress is null ? null : new Progress<string>(x => progress.Report(new WordOperationProgress { Stage = "Generate", Message = x })), cancellationToken); await _word.ExportPdfAsync(result.OutputDocxPath, outputPdf, cancellationToken);
var validation = Validate(result.OutputDocxPath, opened.Template.TemplateId); var validationPath = Path.ChangeExtension(request.OutputDocxPath, ".validation.json"); File.WriteAllText(validationPath, JsonSerializer.Serialize(validation, new JsonSerializerOptions { WriteIndented = true })); return new TemplateGenerationResult { OutputDocxPath = result.OutputDocxPath, OutputPdfPath = outputPdf, GenerationLogPath = result.LogPath, ValidationLogPath = validationPath, Validation = validation };
}
finally { TryDeleteDirectory(opened.WorkingDirectory); }
}
private IReadOnlyList<RuleEvaluationResult> Validate(string output, string templateId)
{
var inspection = _inspector.Inspect(output); var result = new List<RuleEvaluationResult> { new() { RuleId = "DOCX_EXISTS", Status = File.Exists(output) ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, Message = "Output DOCX exists.", DocumentLocation = output }, new() { RuleId = "NO_BODY_MARKER", Status = inspection.InsertionPoint.Type == "None" ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.NeedsReview, Message = inspection.InsertionPoint.Type == "None" ? "No unresolved insertion marker remains." : "An insertion marker remains in the output.", DocumentLocation = "word/document.xml" }, new() { RuleId = "NO_TRACKED_CHANGES", Status = inspection.HasTrackedChanges ? RuleEvaluationStatus.Fail : RuleEvaluationStatus.Pass, Message = inspection.HasTrackedChanges ? "Tracked changes remain." : "No tracked changes detected." }, new() { RuleId = "TEMPLATE_ID", Status = RuleEvaluationStatus.Pass, Message = $"Generated from template {templateId}." } }; return result;
}
private static void ReplaceScalarTokens(string path, IReadOnlyDictionary<string, string> values)
{
if (values.Count == 0) return; using var archive = ZipFile.Open(path, ZipArchiveMode.Update); var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
foreach (var entry in archive.Entries.Where(x => x.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase) && x.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)).ToArray())
{
XDocument document; using (var reader = new StreamReader(entry.Open())) document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace); var changed = false; foreach (var text in document.Descendants(w + "t")) { var value = text.Value; foreach (var pair in values) { var token = "{{" + pair.Key + "}}"; if (!value.Contains(token, StringComparison.OrdinalIgnoreCase)) continue; value = value.Replace(token, pair.Value, StringComparison.OrdinalIgnoreCase); changed = true; } text.Value = value; } if (!changed) continue; entry.Delete(); var replacement = archive.CreateEntry(entry.FullName, CompressionLevel.Optimal); using var stream = replacement.Open(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); document.Save(writer, SaveOptions.DisableFormatting);
}
}
private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } }
public void Dispose() { _word.Dispose(); if (_normalizer is IDisposable disposable) disposable.Dispose(); }
}
@@ -0,0 +1,42 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using DocumentAutomation.Shared;
using DocxWorkbench.Core;
namespace DocxWorkbench.WordInterop;
public sealed class WordDocumentNormalizer : IWordDocumentNormalizer, IDisposable
{
private readonly StaWordQueue _queue;
public WordDocumentNormalizer() : this(new StaWordQueue(), true) { }
internal WordDocumentNormalizer(StaWordQueue queue) : this(queue, false) { }
private readonly bool _ownsQueue;
private WordDocumentNormalizer(StaWordQueue queue, bool ownsQueue) { _queue = queue; _ownsQueue = ownsQueue; }
public Task<DocumentNormalizationResult> NormalizeAsync(DocumentNormalizationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
=> _queue.Enqueue(() => NormalizeOnSta(request, progress, cancellationToken), cancellationToken);
private static DocumentNormalizationResult NormalizeOnSta(DocumentNormalizationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken token)
{
if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Document source was not found.", request.SourcePath);
var extension = Path.GetExtension(request.SourcePath);
if (!extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException("Template source must be DOC or DOCX.");
var started = Stopwatch.StartNew(); var sourceHash = Hash(request.SourcePath); var work = Path.GetFullPath(request.WorkingDirectory); Directory.CreateDirectory(work); var normalized = Path.Combine(work, "normalized.docx"); token.ThrowIfCancellationRequested();
if (extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) { File.Copy(request.SourcePath, normalized, true); return new DocumentNormalizationResult { SourcePath = request.SourcePath, NormalizedPath = normalized, SourceSha256 = sourceHash, NormalizedSha256 = Hash(normalized), WasConverted = false, Duration = started.Elapsed }; }
dynamic? application = null; dynamic? documents = null; dynamic? document = null; var proxies = new List<object?>();
try
{
progress?.Report(new WordOperationProgress { Stage = "NormalizeDoc", Message = "Opening binary DOC read-only" }); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; if (request.DisableMacros) { try { application.AutomationSecurity = 3; } catch { } } documents = Own(application.Documents, proxies); document = Own(documents.Open(request.SourcePath, false, true, false), proxies) ?? throw new WordAutomationException("Word could not open the DOC source read-only."); token.ThrowIfCancellationRequested(); progress?.Report(new WordOperationProgress { Stage = "NormalizeDoc", Message = "Saving temporary DOCX" }); document.SaveAs2(normalized, 12); int? pageCount = null; try { document.Repaginate(); pageCount = (int)document.ComputeStatistics(2); } catch { } document.Close(0); document = null; return new DocumentNormalizationResult { SourcePath = request.SourcePath, NormalizedPath = normalized, SourceSha256 = sourceHash, NormalizedSha256 = Hash(normalized), WasConverted = true, Duration = started.Elapsed, PageCount = pageCount };
}
catch (WordNotInstalledException) { throw; }
catch (WordAutomationException) { throw; }
catch (Exception ex) { throw new WordAutomationException("DOC normalization failed.", ex); }
finally { try { if (document is not null) document.Close(0); } catch { } try { if (application is not null) application.Quit(0); } catch { } ReleaseAll(proxies); }
}
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
private static void ReleaseAll(IEnumerable<object?> proxies) { foreach (var value in proxies.Reverse()) try { if (value is not null && Marshal.IsComObject(value)) Marshal.FinalReleaseComObject(value); } catch { } }
public void Dispose() { if (_ownsQueue) _queue.Dispose(); }
}
@@ -0,0 +1,132 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using DocumentAutomation.Shared;
using DocxWorkbench.Core;
namespace DocxWorkbench.WordInterop;
public sealed class WordNotInstalledException(string message) : InvalidOperationException(message);
public sealed class WordAutomationException(string message, Exception? inner = null) : InvalidOperationException(message, inner);
public sealed class WordDocumentService : IWordDocumentService, IDisposable
{
private readonly StaWordQueue _queue = new();
private readonly DocxBodyImporter _bodyImporter;
public WordDocumentService() => _bodyImporter = new DocxBodyImporter(_queue);
public Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken)
=> _queue.Enqueue(() => BuildOnSta(request, progress, cancellationToken), cancellationToken);
public Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken)
=> _queue.Enqueue(() => ExportPdfOnSta(docxPath, outputPdfPath, cancellationToken), cancellationToken);
private WordDocumentResult BuildOnSta(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken token)
{
DocumentInputValidation.Validate(request);
if (!request.BodyPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) && !request.BodyPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Unsupported body format: {Path.GetExtension(request.BodyPath)}. Use DOCX or TXT.");
var insertion = TemplateInsertionPointReader.Find(request.TemplatePath);
if (insertion.Type == "None") throw new WordAutomationException("No DOC_BODY insertion point was found. Add a DOC_BODY content control, DOC_BODY bookmark, or {{DOC_BODY}} marker.");
var started = Stopwatch.StartNew(); var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); var workingTemplate = Path.Combine(temp, "template.docx"); var stagedOutput = Path.Combine(temp, "result.docx"); File.Copy(request.TemplatePath, workingTemplate, true);
dynamic? application = null; dynamic? documents = null; dynamic? document = null; dynamic? range = null; var proxies = new List<object?>(); int? wordPid = null; BodyImportResult? bodyImport = null;
try
{
token.ThrowIfCancellationRequested(); progress?.Report("Checking Word.Application COM registration"); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; TrySetAutomationSecurity(application); wordPid = TryGetPid(application); progress?.Report($"Word COM started (PID {wordPid?.ToString() ?? "unknown"})");
documents = Own(application.Documents, proxies); document = Own(documents.Open(workingTemplate, false, false, false), proxies) ?? throw new WordAutomationException("Word could not open the working template copy.");
range = Own(FindInsertionRange(document, insertion.Type, proxies), proxies) ?? throw new WordAutomationException("The insertion point disappeared while opening the template.");
token.ThrowIfCancellationRequested(); progress?.Report($"Inserting {Path.GetExtension(request.BodyPath).ToUpperInvariant()} body"); if (request.BodyPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) range.Text = File.ReadAllText(request.BodyPath).Replace("\r\n", "\r").Replace('\n', '\r'); else bodyImport = _bodyImporter.ImportOnSta(application, document, range, new DocxBodyImportRequest { SourcePath = request.BodyPath, DestinationRangeHandle = range }, progress is null ? null : new Progress<WordOperationProgress>(x => progress.Report(x.Message ?? x.Stage)), token, proxies);
token.ThrowIfCancellationRequested(); progress?.Report("Updating fields and pagination"); document.Repaginate(); document.Fields.Update(); UpdateHeadersFooters(document, proxies); document.SaveAs2(stagedOutput, 12); document.Close(0); document = null; RemoveUnreferencedHeaderFooterParts(stagedOutput); CommitOutput(stagedOutput, request.OutputDocxPath);
var logPath = Path.ChangeExtension(request.OutputDocxPath, ".generation.json"); File.WriteAllText(logPath, JsonSerializer.Serialize(new { operationId = Guid.NewGuid(), operationType = "BuildDocument", startedAtUtc = DateTime.UtcNow.Subtract(started.Elapsed), completedAtUtc = DateTime.UtcNow, template = request.TemplatePath, templateSha256 = Hash(request.TemplatePath), templateVersion = "1.0", body = request.BodyPath, sourceSha256 = Hash(request.BodyPath), output = request.OutputDocxPath, resultSha256 = Hash(request.OutputDocxPath), insertionPoint = insertion.Type, wordPid, staThread = Thread.CurrentThread.ManagedThreadId, staApartment = Thread.CurrentThread.GetApartmentState().ToString(), durationMs = started.ElapsedMilliseconds, status = "Succeeded", errorCode = (string?)null }, new JsonSerializerOptions { WriteIndented = true }));
return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion.Type, BodyImport = bodyImport };
}
catch (WordNotInstalledException) { throw; }
catch (WordAutomationException) { throw; }
catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("Word automation failed. See technical details in the operation log.", ex); }
finally
{
try { if (document is not null) document.Close(0); } catch { }
try { if (application is not null) application.Quit(0); } catch { }
ReleaseAll(proxies); TryDeleteDirectory(temp);
}
}
private static WordPreviewResult ExportPdfOnSta(string docxPath, string outputPdfPath, CancellationToken token)
{
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPdfPath))!); var sw = Stopwatch.StartNew(); dynamic? application = null; dynamic? documents = null; dynamic? document = null; var proxies = new List<object?>();
try { token.ThrowIfCancellationRequested(); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; TrySetAutomationSecurity(application); documents = Own(application.Documents, proxies); document = Own(documents.Open(docxPath, true, false, false), proxies) ?? throw new WordAutomationException("Word could not open the DOCX."); document.ExportAsFixedFormat(outputPdfPath, 17); return new WordPreviewResult { PdfPath = outputPdfPath, Duration = sw.Elapsed }; }
catch (WordNotInstalledException) { throw; }
catch (WordAutomationException) { throw; }
catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("PDF export through Word failed.", ex); }
finally { try { if (document is not null) document.Close(0); } catch { } try { if (application is not null) application.Quit(0); } catch { } ReleaseAll(proxies); }
}
private static dynamic? FindInsertionRange(dynamic document, string type, List<object?> proxies)
{
if (type == "ContentControl") { dynamic controls = Own(document.ContentControls, proxies); for (var i = 1; i <= controls.Count; i++) { dynamic control = Own(controls[i], proxies); if (string.Equals((string?)control.Tag, "DOC_BODY", StringComparison.OrdinalIgnoreCase) || string.Equals((string?)control.Title, "DOC_BODY", StringComparison.OrdinalIgnoreCase)) return control.Range; } }
if (type == "Bookmark") return document.Bookmarks["DOC_BODY"].Range;
dynamic search = Own(document.Content, proxies); search.Find.ClearFormatting(); search.Find.Text = "{{DOC_BODY}}"; if (search.Find.Execute()) { search.Text = ""; return search; } return null;
}
private static void UpdateHeadersFooters(dynamic document, List<object?> proxies)
{
dynamic sections = Own(document.Sections, proxies); for (var i = 1; i <= sections.Count; i++) { dynamic section = Own(sections[i], proxies); UpdateCollection(section.Headers, proxies); UpdateCollection(section.Footers, proxies); }
void UpdateCollection(dynamic collection, List<object?> proxies) { dynamic owned = WordDocumentService.Own(collection, proxies); for (var i = 1; i <= owned.Count; i++) { dynamic item = WordDocumentService.Own(owned[i], proxies); dynamic range = WordDocumentService.Own(item.Range, proxies); range.Fields.Update(); } }
}
private static void TrySetAutomationSecurity(dynamic application) { try { application.AutomationSecurity = 3; application.ScreenUpdating = false; } catch { } }
private static int? TryGetPid(dynamic application) { try { return Process.GetProcessById((int)application.Hwnd).Id; } catch { return null; } }
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
private static void ReleaseAll(IEnumerable<object?> proxies) { foreach (var proxy in proxies.Reverse()) try { if (proxy is not null && Marshal.IsComObject(proxy)) Marshal.FinalReleaseComObject(proxy); } catch { } }
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
private static void CommitOutput(string staged, string output) { var full = Path.GetFullPath(output); if (File.Exists(full)) File.Copy(full, full + ".bak-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"), true); File.Copy(staged, full, true); }
private static void RemoveUnreferencedHeaderFooterParts(string path)
{
using var archive = ZipFile.Open(path, ZipArchiveMode.Update);
var documentEntry = archive.GetEntry("word/document.xml");
var relsEntry = archive.GetEntry("word/_rels/document.xml.rels");
if (documentEntry is null || relsEntry is null) return;
XDocument document;
XDocument relationships;
using (var reader = new StreamReader(documentEntry.Open())) document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace);
using (var reader = new StreamReader(relsEntry.Open())) relationships = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace);
var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
var rel = XNamespace.Get("http://schemas.openxmlformats.org/package/2006/relationships");
var used = document.Descendants(w + "headerReference").Concat(document.Descendants(w + "footerReference")).Select(x => (string?)x.Attribute(XNamespace.Get("http://schemas.openxmlformats.org/officeDocument/2006/relationships") + "id")).Where(x => x is not null).ToHashSet(StringComparer.Ordinal);
var removedTargets = new List<string>();
foreach (var relationship in relationships.Root?.Elements(rel + "Relationship").ToList() ?? [])
{
var type = (string?)relationship.Attribute("Type") ?? string.Empty;
var id = (string?)relationship.Attribute("Id");
if (id is null || (!type.EndsWith("/header", StringComparison.OrdinalIgnoreCase) && !type.EndsWith("/footer", StringComparison.OrdinalIgnoreCase)) || used.Contains(id)) continue;
var target = (string?)relationship.Attribute("Target");
if (!string.IsNullOrWhiteSpace(target)) removedTargets.Add(ResolveWordPart(target));
relationship.Remove();
}
foreach (var target in removedTargets.Distinct(StringComparer.OrdinalIgnoreCase)) archive.GetEntry(target)?.Delete();
relsEntry.Delete();
var replacement = archive.CreateEntry("word/_rels/document.xml.rels", CompressionLevel.Optimal);
using var stream = replacement.Open();
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
relationships.Save(writer, SaveOptions.DisableFormatting);
}
private static string ResolveWordPart(string target) => target.StartsWith("/", StringComparison.Ordinal) ? target.TrimStart('/') : "word/" + target.Replace("\\", "/").TrimStart("./".ToCharArray());
private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } }
public void Dispose() => _queue.Dispose();
}
internal static class TemplateInsertionPointReader
{
public static InsertionPointInfo Find(string docxPath)
{
using var archive = ZipFile.OpenRead(docxPath); var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("DOCX document.xml is missing."); using var reader = new StreamReader(entry.Open()); var xml = reader.ReadToEnd();
if (Regex.IsMatch(xml, @"w:tag[^>]+w:val=""DOC_BODY""", RegexOptions.IgnoreCase)) return new InsertionPointInfo { Type = "ContentControl", Location = "word/document.xml" };
if (Regex.IsMatch(xml, @"w:bookmarkStart[^>]+w:name=""DOC_BODY""", RegexOptions.IgnoreCase)) return new InsertionPointInfo { Type = "Bookmark", Location = "word/document.xml" };
if (xml.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return new InsertionPointInfo { Type = "TextMarker", Location = "word/document.xml" };
return new InsertionPointInfo { Type = "None" };
}
}
@@ -0,0 +1,22 @@
using System.Text.Json;
using StandardsWorkbench.Core;
using StandardsWorkbench.Ollama;
using StandardsWorkbench.Pdf;
namespace StandardsWorkbench.App;
public sealed class MainForm : Form
{
private readonly TextBox _pdf = new() { Width = 560 }; private readonly TextBox _code = new() { Width = 240, Text = "ГОСТ Р 21.101-2026" }; private readonly TextBox _title = new() { Width = 560, Text = "Нормативный документ" }; private readonly TextBox _log = new() { Multiline = true, Dock = DockStyle.Fill, ScrollBars = ScrollBars.Vertical }; private readonly ListBox _candidates = new() { Dock = DockStyle.Fill }; private readonly Label _status = new() { AutoSize = true, Text = "Готово" }; private readonly IStandardPdfService _service = new PdfStandardService(); private StandardMetadata? _metadata; private List<CandidateRule> _rules = [];
public MainForm()
{
Text = "Standards Workbench"; Width = 900; Height = 620; var top = new TableLayoutPanel { Dock = DockStyle.Top, Height = 150, ColumnCount = 3, RowCount = 4, Padding = new Padding(8) }; top.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 120)); top.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); top.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110)); Add(top, 0, "Стандарт PDF", _pdf, Browse); Add(top, 1, "Код", _code, null); Add(top, 2, "Название", _title, null); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; buttons.Controls.Add(Button("Анализировать PDF", async (_, _) => await ImportAsync())); buttons.Controls.Add(Button("Утвердить выбранное", (_, _) => SetSelectedStatus("Approved"))); buttons.Controls.Add(Button("Отклонить выбранное", (_, _) => SetSelectedStatus("Rejected"))); buttons.Controls.Add(Button("Экспортировать профиль", (_, _) => ExportProfile())); top.Controls.Add(buttons, 1, 3); top.SetColumnSpan(buttons, 2); var tabs = new TabControl { Dock = DockStyle.Fill }; foreach (var t in new[] { "Документ", "Страницы", "Пункты", "Кандидаты правил", "Профили", "Сравнение", "Журнал" }) { var p = new TabPage(t); if (t == "Журнал") p.Controls.Add(_log); if (t == "Кандидаты правил") p.Controls.Add(_candidates); tabs.TabPages.Add(p); } Controls.Add(tabs); Controls.Add(top); Controls.Add(_status); _status.Dock = DockStyle.Bottom;
}
private static void Add(TableLayoutPanel p, int row, string label, TextBox box, EventHandler? browse) { p.Controls.Add(new Label { Text = label, AutoSize = true }, 0, row); p.Controls.Add(box, 1, row); if (browse is not null) { var b = new Button { Text = "Обзор..." }; b.Click += browse; p.Controls.Add(b, 2, row); } }
private static Button Button(string text, EventHandler e) { var b = new Button { Text = text, AutoSize = true }; b.Click += e; return b; }
private void Browse(object? s, EventArgs e) { using var d = new OpenFileDialog { Filter = "PDF|*.pdf" }; if (d.ShowDialog() == DialogResult.OK) _pdf.Text = d.FileName; }
private async Task ImportAsync() { try { _status.Text = "Анализ PDF..."; var result = await _service.ImportAsync(_pdf.Text, _code.Text, _title.Text, CancellationToken.None); _metadata = result.Metadata; _rules = result.Clauses.Select(c => new CandidateRule { CandidateId = Guid.NewGuid().ToString("N"), StandardId = c.StandardId, ClauseId = c.ClauseId, SourcePage = c.StartPage, SourceText = c.Text, Title = c.Heading.Length > 0 ? c.Heading : $"Кандидат по пункту {c.ClauseId}", RequiresHumanApproval = true }).ToList(); RefreshCandidates(); _log.AppendText(JsonSerializer.Serialize(new { result.Metadata, result.Pages, result.Clauses }, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine); _status.Text = $"Готово: {result.Pages.Count} страниц, {result.Clauses.Count} пунктов"; } catch (Exception ex) { _log.AppendText(ex + Environment.NewLine); _status.Text = "Ошибка"; MessageBox.Show(ex.Message); } }
private void RefreshCandidates() { _candidates.Items.Clear(); foreach (var rule in _rules) _candidates.Items.Add($"[{rule.Status}] {rule.ClauseId} стр.{rule.SourcePage}: {rule.Title}"); }
private void SetSelectedStatus(string status) { if (_candidates.SelectedIndex < 0 || _candidates.SelectedIndex >= _rules.Count) return; _rules[_candidates.SelectedIndex] = _rules[_candidates.SelectedIndex] with { Status = status }; RefreshCandidates(); }
private void ExportProfile() { if (_metadata is null) { MessageBox.Show("Сначала импортируйте PDF."); return; } using var dialog = new FolderBrowserDialog(); if (dialog.ShowDialog() != DialogResult.OK) return; var profile = new ApprovedRulesProfile { StandardId = _metadata.StandardId, Code = _metadata.Code, Revision = _metadata.Revision, SourceFileSha256 = _metadata.SourceFileSha256, Rules = _rules.Where(r => r.Status == "Approved").ToArray() }; var path = Path.Combine(dialog.SelectedPath, $"{_metadata.StandardId}-docx-profile"); ProfileStore.Export(path, profile); _status.Text = $"Профиль экспортирован: {path}"; }
}
@@ -0,0 +1,16 @@
namespace StandardsWorkbench.App;
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
<ProjectReference Include="..\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj" />
<ProjectReference Include="..\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj" />
<ProjectReference Include="..\StandardsWorkbench.Ollama\StandardsWorkbench.Ollama.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,77 @@
using System.Security.Cryptography;
using System.Text.Json;
using DocumentAutomation.Shared;
namespace StandardsWorkbench.Core;
public sealed record StandardMetadata
{
public required string StandardId { get; init; }
public required string Code { get; init; }
public required string Title { get; init; }
public string Revision { get; init; } = "";
public DateOnly? EffectiveFrom { get; init; }
public required string SourceFileSha256 { get; init; }
public int PageCount { get; init; }
public bool TextLayerDetected { get; init; }
public DateTime ImportedAt { get; init; } = DateTime.UtcNow;
public string Status { get; init; } = "Imported";
}
public sealed record PdfPageText(int PageNumber, string Text, bool TextLayerDetected, string Status, IReadOnlyList<string> Blocks);
public sealed record StandardClause(string StandardId, string ClauseId, string Heading, string Text, int StartPage, int EndPage, double ExtractionConfidence);
public sealed record CandidateRule
{
public required string CandidateId { get; init; }
public required string StandardId { get; init; }
public required string ClauseId { get; init; }
public int SourcePage { get; init; }
public required string SourceText { get; init; }
public required string Title { get; init; }
public string[] Scope { get; init; } = ["DOCX"];
public string RuleKind { get; init; } = "ExpertReview";
public string Target { get; init; } = "Document";
public Dictionary<string, string> Parameters { get; init; } = [];
public string Severity { get; init; } = "Warning";
public string AutomationLevel { get; init; } = "Manual";
public double Confidence { get; init; }
public bool RequiresHumanApproval { get; init; } = true;
public string Status { get; init; } = "New";
}
public sealed record ApprovedRulesProfile
{
public required string StandardId { get; init; }
public required string Code { get; init; }
public required string Revision { get; init; }
public required string SourceFileSha256 { get; init; }
public string ProfileVersion { get; init; } = "1.0";
public string MinimumRulesEngineVersion { get; init; } = "1.0";
public DateTime ExportedAt { get; init; } = DateTime.UtcNow;
public IReadOnlyList<CandidateRule> Rules { get; init; } = [];
}
public interface IPdfOcrService { Task<PdfPageText> RecognizePageAsync(string pdfPath, int pageNumber, CancellationToken cancellationToken); }
public interface IStandardPdfService { Task<(StandardMetadata Metadata, IReadOnlyList<PdfPageText> Pages, IReadOnlyList<StandardClause> Clauses)> ImportAsync(string pdfPath, string code, string title, CancellationToken cancellationToken); }
public interface IOllamaService { Task<IReadOnlyList<string>> GetModelsAsync(CancellationToken cancellationToken); Task<CandidateRule?> SuggestCandidateAsync(StandardClause clause, string model, CancellationToken cancellationToken); }
public static class ProfileStore
{
public static string Sha256(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
public static void Export(string directory, ApprovedRulesProfile profile)
{
Directory.CreateDirectory(directory); var options = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(Path.Combine(directory, "manifest.json"), JsonSerializer.Serialize(profile, options)); File.WriteAllText(Path.Combine(directory, "approved-rules.json"), JsonSerializer.Serialize(profile.Rules, options)); File.WriteAllText(Path.Combine(directory, "references.json"), JsonSerializer.Serialize(profile.Rules.Select(r => new { r.ClauseId, r.SourcePage, r.StandardId }), options)); File.WriteAllText(Path.Combine(directory, "signature.sha256"), Sha256(Path.Combine(directory, "approved-rules.json")));
}
}
public sealed class RulesEngine
{
public IReadOnlyList<RuleEvaluationResult> Evaluate(DocumentInspectionResult document, ApprovedRulesProfile profile)
=> profile.Rules.Where(r => r.Status is "Approved" or "Implemented" or "Tested").Select(r => EvaluateOne(document, r)).ToArray();
private static RuleEvaluationResult EvaluateOne(DocumentInspectionResult d, CandidateRule rule)
{
var status = rule.RuleKind switch { "SectionCount" => d.Sections > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "ContentControlPresence" => d.ContentControls > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "BookmarkPresence" => d.Bookmarks > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "CommentsAbsent" => d.Comments == 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "TrackedChangesAbsent" => !d.HasTrackedChanges ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, _ => RuleEvaluationStatus.NeedsReview };
return new RuleEvaluationResult { RuleId = rule.CandidateId, Status = status, Message = status == RuleEvaluationStatus.Pass ? "Проверка пройдена." : status == RuleEvaluationStatus.Fail ? "Проверка не пройдена." : "Требуется экспертная проверка.", ClauseId = rule.ClauseId, SourcePage = rule.SourcePage, DocumentLocation = rule.Target, CanAutoFix = false };
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup><ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,17 @@
using System.Net.Http.Json;
using System.Text.Json;
using StandardsWorkbench.Core;
namespace StandardsWorkbench.Ollama;
public sealed class OllamaClient : IOllamaService
{
private readonly HttpClient _http;
public OllamaClient(HttpClient? httpClient = null) { _http = httpClient ?? new HttpClient(); _http.BaseAddress = new Uri("http://192.168.200.112:11434"); _http.Timeout = TimeSpan.FromSeconds(120); }
public async Task<IReadOnlyList<string>> GetModelsAsync(CancellationToken cancellationToken)
{ using var response = await _http.GetAsync("/api/tags", cancellationToken); response.EnsureSuccessStatusCode(); using var json = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken: cancellationToken); return json?.RootElement.GetProperty("models").EnumerateArray().Select(x => x.GetProperty("name").GetString() ?? "").Where(x => x.Length > 0).ToArray() ?? []; }
public async Task<CandidateRule?> SuggestCandidateAsync(StandardClause clause, string model, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(model)) throw new ArgumentException("Модель Ollama не выбрана.", nameof(model)); var prompt = $"Return JSON only. Extract one candidate DOCX rule from clause {clause.ClauseId}. Never invent source references. Text: {clause.Text}"; using var response = await _http.PostAsJsonAsync("/api/generate", new { model, prompt, stream = false, format = "json" }, cancellationToken); response.EnsureSuccessStatusCode(); using var json = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken: cancellationToken); var raw = json?.RootElement.GetProperty("response").GetString(); if (string.IsNullOrWhiteSpace(raw)) return null; try { return JsonSerializer.Deserialize<CandidateRule>(raw); } catch (JsonException) { return null; }
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup><ProjectReference Include="..\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,33 @@
using System.Text.RegularExpressions;
using StandardsWorkbench.Core;
using UglyToad.PdfPig;
namespace StandardsWorkbench.Pdf;
public sealed class PdfStandardService : IStandardPdfService
{
public Task<(StandardMetadata Metadata, IReadOnlyList<PdfPageText> Pages, IReadOnlyList<StandardClause> Clauses)> ImportAsync(string pdfPath, string code, string title, CancellationToken cancellationToken)
{
if (!File.Exists(pdfPath)) throw new FileNotFoundException("PDF ГОСТа не найден.", pdfPath);
return Task.Run(() =>
{
using var pdf = PdfDocument.Open(pdfPath); var pages = new List<PdfPageText>();
foreach (var page in pdf.GetPages()) { cancellationToken.ThrowIfCancellationRequested(); var text = page.Text ?? ""; pages.Add(new PdfPageText(page.Number, text, text.Trim().Length > 0, text.Trim().Length > 0 ? "Extracted" : "OCR_REQUIRED", text.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))); }
var standardId = Regex.Replace(code.ToUpperInvariant(), "[^A-ZА-Я0-9]+", "-").Trim('-'); var clauses = ExtractClauses(standardId, pages); var meta = new StandardMetadata { StandardId = standardId, Code = code, Title = title, SourceFileSha256 = ProfileStore.Sha256(pdfPath), PageCount = pages.Count, TextLayerDetected = pages.Any(p => p.TextLayerDetected), ImportedAt = DateTime.UtcNow };
return (meta, (IReadOnlyList<PdfPageText>)pages, (IReadOnlyList<StandardClause>)clauses);
}, cancellationToken);
}
private static List<StandardClause> ExtractClauses(string id, IReadOnlyList<PdfPageText> pages)
{
var result = new List<StandardClause>(); string? currentId = null; var currentText = new List<string>(); var start = 1;
void Flush(int end) { if (currentId is not null) result.Add(new StandardClause(id, currentId, "", string.Join(Environment.NewLine, currentText), start, end, 0.85)); }
foreach (var page in pages) foreach (var line in page.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var match = Regex.Match(line, "^(\\d+(?:\\.\\d+){0,5})[.)]?\\s+(.+)$"); if (match.Success) { Flush(page.PageNumber - 1); currentId = match.Groups[1].Value; currentText = [line]; start = page.PageNumber; } else if (currentId is not null) currentText.Add(line); }
Flush(pages.Count); return result;
}
}
public sealed class NoOpOcrService : IPdfOcrService
{
public Task<PdfPageText> RecognizePageAsync(string pdfPath, int pageNumber, CancellationToken cancellationToken) => Task.FromResult(new PdfPageText(pageNumber, "", false, "OCR_REQUIRED", []));
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
<ProjectReference Include="..\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj" />
<PackageReference Include="PdfPig" Version="0.1.9" />
</ItemGroup>
</Project>
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup><ProjectReference Include="..\..\src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" /><ProjectReference Include="..\..\src\DocxWorkbench.OpenXml\DocxWorkbench.OpenXml.csproj" /><ProjectReference Include="..\..\src\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj" /><PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" /></ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Text.Json;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocumentAutomation.Shared;
using DocxWorkbench.WordInterop;
using DocxWorkbench.OpenXml;
using Xunit.Sdk;
namespace DocumentAutomation.IntegrationTests;
public sealed class WordComIntegrationTests
{
[Fact]
public void IntegrationAssemblyIsAvailable() => Assert.True(typeof(WordComIntegrationTests).Assembly.GetName().Name is not null);
[Fact]
public void WordAvailability_DoesNotRequireOfficePia()
{
var names = typeof(WordDocumentService).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray(); Assert.DoesNotContain(names, name => string.Equals(name, "office", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "Microsoft.Office.Interop.Word", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Build_WithoutInsertionPoint_DoesNotStartWord()
{
var template = CreateDocx(false); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); File.WriteAllText(body, "text"); var before = ProcessIds(); try { using var service = new WordDocumentService(); await Assert.ThrowsAsync<WordAutomationException>(() => service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx") }, null, CancellationToken.None)); Assert.Empty(ProcessIds().Except(before)); } finally { File.Delete(template); File.Delete(body); }
}
[Fact] public async Task Build_RunsOnStaThread() { await RequireWord(); var result = await BuildTxt(); var log = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(result.LogPath)); Assert.Equal("STA", log.GetProperty("staApartment").GetString()); }
[Fact] public async Task Build_WithTxtBody_InsertsText() { await RequireWord(); var result = await BuildTxt(); Assert.Contains("TXT integration body", ReadMainText(result.OutputDocxPath)); }
[Fact] public async Task Build_WithDocxBody_InsertsDocument() { await RequireWord(); var template = CreateDocx(true); var body = CreateDocx(false, "DOCX integration body"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); try { using var service = new WordDocumentService(); await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); Assert.Contains("DOCX integration body", ReadMainText(output)); } finally { File.Delete(template); File.Delete(body); File.Delete(output); } }
[Fact] public async Task Build_ExportsPdf() { await RequireWord(); var result = await BuildTxt(); using var service = new WordDocumentService(); var pdf = await service.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); Assert.True(File.Exists(pdf.PdfPath)); File.Delete(result.OutputDocxPath); File.Delete(pdf.PdfPath); File.Delete(result.LogPath); }
[Fact] public async Task Build_DoesNotLeaveCreatedWinwordProcess() { await RequireWord(); var before = ProcessIds(); var result = await BuildTxt(); await Task.Delay(500); Assert.Empty(ProcessIds().Except(before)); File.Delete(result.OutputDocxPath); File.Delete(result.LogPath); }
[Fact] public async Task Build_Failure_ReleasesWord() { await RequireWord(); var before = ProcessIds(); var template = CreateDocx(true); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var outputDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); Directory.CreateDirectory(outputDirectory); File.WriteAllText(body, "failure body"); try { using var service = new WordDocumentService(); await Assert.ThrowsAnyAsync<Exception>(() => service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = outputDirectory }, null, CancellationToken.None)); await Task.Delay(500); Assert.Empty(ProcessIds().Except(before)); } finally { File.Delete(template); File.Delete(body); Directory.Delete(outputDirectory, true); } }
[Fact]
public async Task RuntimeGate_DesktopDocxBodyUsesTemplateCopy()
{
await RequireWord(); var template = @"C:\Users\codex\Desktop\156-титул-ПД.docx"; var body = @"C:\Users\codex\Desktop\НХС-33-УК3-ОЗХ-13011-000-НК-ОД.docx"; if (!File.Exists(template) || !File.Exists(body)) throw SkipException.ForSkip("The supplied Desktop DOCX fixtures are not available."); var copy = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); File.Copy(template, copy); try { using (var document = WordprocessingDocument.Open(copy, true)) { var main = document.MainDocumentPart ?? throw new InvalidDataException("Main part missing."); main.Document.Body!.Append(new Paragraph(new Run(new Text("{{DOC_BODY}}")))); main.Document.Save(); } using var service = new WordDocumentService(); var result = await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = copy, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); Assert.True(File.Exists(result.OutputDocxPath)); } finally { File.Delete(copy); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".generation.json")); }
}
[Fact]
public async Task ImportDocxBody_ExcludesSourceHeaders()
{
await RequireWord(); var template = CreateDocx(true); var source = CreateComplexSource(); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var sourceHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(source))); try { using var service = new WordDocumentService(); var result = await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = source, OutputDocxPath = output }, null, CancellationToken.None); using var document = WordprocessingDocument.Open(result.OutputDocxPath, false); var main = document.MainDocumentPart!; Assert.DoesNotContain(main.HeaderParts.SelectMany(x => x.Header?.Descendants<Text>() ?? []), x => x.Text == "SOURCE HEADER"); Assert.DoesNotContain(main.FooterParts.SelectMany(x => x.Footer?.Descendants<Text>() ?? []), x => x.Text == "SOURCE FOOTER"); Assert.Single(main.Document.Descendants<SectionProperties>()); Assert.NotNull(result.BodyImport); Assert.Contains("wdMainTextStory", result.BodyImport!.Plan.IncludedStories); Assert.Equal(sourceHash, Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(source)))); } finally { File.Delete(template); File.Delete(source); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".generation.json")); }
}
[Fact]
public async Task ImportDocxBody_DoesNotChangeDestinationPageSetup()
{
await RequireWord(); var template = CreateDocx(true); var source = CreateComplexSource(); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); try { using var service = new WordDocumentService(); var result = await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = source, OutputDocxPath = output }, null, CancellationToken.None); using var document = WordprocessingDocument.Open(result.OutputDocxPath, false); Assert.Single(document.MainDocumentPart!.Document.Descendants<SectionProperties>()); Assert.Contains("DOCX complex body", document.MainDocumentPart.Document.InnerText); } finally { File.Delete(template); File.Delete(source); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".generation.json")); }
}
[Fact]
public async Task TemplateLearning_EndToEnd_RealDocToDtemplateToDocxAndPdf()
{
await RequireWord(); var source = @"C:\Users\codex\Desktop\НХС-33-УК3-ОЗХ-13011-000-НК-ОД.doc"; var content = @"C:\Users\codex\Desktop\НХС-33-УК3-ОЗХ-13011-000-НК-ОД.docx"; if (!File.Exists(source) || !File.Exists(content)) throw SkipException.ForSkip("The supplied real DOC/DOCX fixtures are not available.");
var package = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".dtemplate"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var sourceHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(source))); var before = ProcessIds();
try
{
using var normalizer = new WordDocumentNormalizer(); var learning = new TemplateLearningService(normalizer, new TemplatePackageStore(), new OpenXmlDocumentInspector()); var learned = await learning.LearnAsync(new TemplateLearningRequest { SourcePath = source, TemplateName = "Real sample", OutputPackagePath = package }, null, CancellationToken.None); Assert.NotEmpty(learned.Slots); Assert.True(File.Exists(package)); using var generator = new TemplateDocumentGenerator(new TemplatePackageStore(), new WordDocumentNormalizer(), new OpenXmlDocumentInspector()); var result = await generator.GenerateAsync(new TemplateGenerationRequest { TemplatePackagePath = package, ContentSourcePath = content, OutputDocxPath = output }, null, CancellationToken.None); Assert.True(File.Exists(result.OutputDocxPath)); Assert.True(File.Exists(result.OutputPdfPath)); Assert.Equal(sourceHash, Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(source)))); Assert.Empty(ProcessIds().Except(before));
}
finally { File.Delete(package); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".pdf")); File.Delete(Path.ChangeExtension(output, ".generation.json")); File.Delete(Path.ChangeExtension(output, ".validation.json")); }
}
private static async Task RequireWord() { if (!OperatingSystem.IsWindows() || Type.GetTypeFromProgID("Word.Application", false) is null) throw SkipException.ForSkip("Microsoft Word COM is not installed or registered on this workstation."); await Task.CompletedTask; }
private static async Task<WordDocumentResult> BuildTxt() { var template = CreateDocx(true); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); File.WriteAllText(body, "TXT integration body"); try { using var service = new WordDocumentService(); return await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); } finally { File.Delete(template); File.Delete(body); } }
private static HashSet<int> ProcessIds() => Process.GetProcessesByName("WINWORD").Select(x => x.Id).ToHashSet();
private static string ReadMainText(string path) { using var doc = WordprocessingDocument.Open(path, false); return doc.MainDocumentPart?.Document.InnerText ?? ""; }
private static string CreateComplexSource()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = doc.AddMainDocumentPart(); var header1 = main.AddNewPart<HeaderPart>(); header1.Header = new Header(new Paragraph(new Run(new Text("SOURCE HEADER")))); var footer1 = main.AddNewPart<FooterPart>(); footer1.Footer = new Footer(new Paragraph(new Run(new Text("SOURCE FOOTER")))); var body = new Body(); var first = new Paragraph(new ParagraphProperties(new SectionProperties(new HeaderReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(header1) }, new FooterReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(footer1) })), new Run(new Text("DOCX complex body"))); body.Append(first); body.Append(new Table(new TableRow(new TableCell(new Paragraph(new Run(new Text("source table"))))))); body.Append(new SectionProperties(new HeaderReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(header1) }, new FooterReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(footer1) })); main.Document = new Document(body); main.Document.Save(); return path;
}
private static string CreateDocx(bool marker, string text = "template body") { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body(new Paragraph(new Run(new Text(marker ? "{{DOC_BODY}}" : text))))); main.Document.Save(); return path; }
}
@@ -0,0 +1,54 @@
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocxWorkbench.Core;
using DocxWorkbench.OpenXml;
namespace DocxWorkbench.Tests;
public sealed class DocumentInspectorTests
{
[Fact]
public void Analyze_DocxTemplateAndTxtBody_Succeeds()
{
var docx = CreateDocx(marker: true); var txt = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); File.WriteAllText(txt, "line one\nline two"); try { var template = new OpenXmlDocumentInspector().Inspect(docx); var body = new TextDocumentInspector().Inspect(txt); Assert.Equal("TextMarker", template.InsertionPoint.Type); Assert.Equal(2, body.Paragraphs); } finally { File.Delete(docx); File.Delete(txt); }
}
[Fact]
public void Analyze_DocxTemplateAndDocxBody_Succeeds()
{
var template = CreateDocx(marker: true); var body = CreateDocx(marker: false); try { var inspector = new OpenXmlDocumentInspector(); Assert.Equal("TextMarker", inspector.Inspect(template).InsertionPoint.Type); Assert.Equal(1, inspector.Inspect(body).Sections); } finally { File.Delete(template); File.Delete(body); }
}
[Fact]
public void Inspect_DetectsMarkerInsertionPoint()
{
var path = CreateDocx(marker: true); try { Assert.Equal("TextMarker", new OpenXmlDocumentInspector().Inspect(path).InsertionPoint.Type); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_FindsPageFieldsInFooter()
{
var path = CreateDocx(marker: true, fields: true); try { var result = new OpenXmlDocumentInspector().Inspect(path); Assert.Contains(result.Fields, field => field.Type == "PAGE" && field.Part.StartsWith("FooterPart", StringComparison.Ordinal)); Assert.Contains(result.Fields, field => field.Type == "NUMPAGES"); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_CountsHeaderFooterGraphics()
{
var path = CreateDocx(marker: true, graphics: true); try { var result = new OpenXmlDocumentInspector().Inspect(path); Assert.True(result.ImageSummary.RasterImages >= 2); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_UnsupportedFormatIsDomainError()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".bin"); File.WriteAllText(path, "not a document"); try { var exception = Assert.Throws<UnsupportedDocumentFormatException>(() => new OpenXmlDocumentInspector().Inspect(path)); Assert.Contains("Open XML inspector", exception.Message); } finally { File.Delete(path); }
}
private static string CreateDocx(bool marker, bool fields = false, bool graphics = false)
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var document = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = document.AddMainDocumentPart(); var body = new Body(new Paragraph(new Run(new Text(marker ? "{{DOC_BODY}}" : "body")))); main.Document = new Document(body);
var header = main.AddNewPart<HeaderPart>(); header.Header = new Header(new Paragraph(new Run(new Text("header")))); var footer = main.AddNewPart<FooterPart>();
if (fields) { var paragraph = new Paragraph(); paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin })); paragraph.Append(new Run(new FieldCode(" PAGE "))); paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End })); paragraph.Append(new SimpleField { Instruction = " NUMPAGES " }); footer.Footer = new Footer(paragraph); } else footer.Footer = new Footer(new Paragraph(new Run(new Text("footer"))));
if (graphics) { var image = header.AddImagePart(ImagePartType.Png); image.FeedData(new MemoryStream([137, 80, 78, 71])); var image2 = footer.AddImagePart(ImagePartType.Png); image2.FeedData(new MemoryStream([137, 80, 78, 71])); }
body.Append(new SectionProperties(new HeaderReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(header) }, new FooterReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(footer) })); main.Document.Save(); return path;
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup><ProjectReference Include="..\..\src\DocxWorkbench.Core\DocxWorkbench.Core.csproj" /><ProjectReference Include="..\..\src\DocxWorkbench.OpenXml\DocxWorkbench.OpenXml.csproj" /><ProjectReference Include="..\..\src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" /><PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" /></ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,22 @@
using DocxWorkbench.WordInterop;
namespace DocxWorkbench.Tests;
public sealed class StaWordQueueTests
{
[Fact]
public async Task Dispose_DrainsActiveJobAndIsIdempotent()
{
using var queue = new StaWordQueue(); using var started = new ManualResetEventSlim(false); using var release = new ManualResetEventSlim(false);
var job = queue.Enqueue(() => { started.Set(); Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); release.Wait(); return 42; }, CancellationToken.None); Assert.True(started.Wait(TimeSpan.FromSeconds(2)));
var dispose = Task.Run(queue.Dispose); var completed = await Task.WhenAny(dispose, Task.Delay(100)); Assert.NotSame(dispose, completed); release.Set(); await dispose; Assert.Equal(42, await job); queue.Dispose(); Assert.Throws<ObjectDisposedException>(() => EnqueueAfterDispose(queue));
}
[Fact]
public async Task Enqueue_CancellationCompletesCanceledTask()
{
using var queue = new StaWordQueue(); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queue.Enqueue(() => 1, cancellation.Token));
}
private static void EnqueueAfterDispose(StaWordQueue queue) => queue.Enqueue(() => 1, CancellationToken.None);
}
@@ -0,0 +1,25 @@
using System.IO.Compression;
using System.Security.Cryptography;
using DocumentAutomation.Shared;
using DocxWorkbench.OpenXml;
using DocxWorkbench.WordInterop;
namespace DocxWorkbench.Tests;
public sealed class TemplateLearningTests
{
[Fact]
public async Task NormalizeDocx_UsesSafeWorkingCopy()
{
var source = CreateDocx("template"); var hash = Hash(source); var working = Path.Combine(Path.GetTempPath(), "learning-test-" + Guid.NewGuid().ToString("N")); using var normalizer = new WordDocumentNormalizer(); try { var result = await normalizer.NormalizeAsync(new DocumentNormalizationRequest { SourcePath = source, WorkingDirectory = working }, null, CancellationToken.None); Assert.False(result.WasConverted); Assert.NotEqual(Path.GetFullPath(source), Path.GetFullPath(result.NormalizedPath)); Assert.Equal(hash, Hash(source)); Assert.Equal(hash, result.SourceSha256); Assert.True(File.Exists(result.NormalizedPath)); } finally { File.Delete(source); if (Directory.Exists(working)) Directory.Delete(working, true); }
}
[Fact]
public async Task TemplatePackage_CanBeCreatedAndReopened()
{
var source = CreateDocx("{{DOC_BODY}}"); var package = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".dtemplate"); using var normalizer = new WordDocumentNormalizer(); try { var service = new TemplateLearningService(normalizer, new TemplatePackageStore()); var learned = await service.LearnAsync(new TemplateLearningRequest { SourcePath = source, TemplateName = "Unit template", OutputPackagePath = package }, null, CancellationToken.None); Assert.NotEmpty(learned.Slots); Assert.Contains(learned.PageArchetypes, x => x.IsFirstPage); using var archive = ZipFile.OpenRead(package); Assert.Contains(archive.Entries, x => x.FullName == "manifest.json"); Assert.Contains(archive.Entries, x => x.FullName == "template.docx"); var opened = new TemplatePackageStore().Open(package); Assert.Equal(learned.TemplateId, opened.Template.TemplateId); Assert.True(File.Exists(opened.TemplateDocxPath)); Directory.Delete(opened.WorkingDirectory, true); } finally { File.Delete(source); File.Delete(package); }
}
private static string CreateDocx(string text) { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var document = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = document.AddMainDocumentPart(); main.Document = new DocumentFormat.OpenXml.Wordprocessing.Document(new DocumentFormat.OpenXml.Wordprocessing.Body(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Text(text))))); main.Document.Save(); return path; }
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
}
@@ -0,0 +1,14 @@
using DocxWorkbench.Core;
namespace DocxWorkbench.Tests;
public sealed class WorkbenchStateTests
{
[Fact]
public void VerifyButton_DisabledBeforeBuild() { var state = new WorkbenchStateController(); Assert.False(state.CanValidate); }
[Fact]
public void VerifyButton_DisabledAfterBuildFailure() { var state = Ready(); state.BeginBuild(); state.BuildFailed(); Assert.False(state.CanValidate); Assert.False(state.CanOpenDocx); }
[Fact]
public void VerifyButton_EnabledAfterBuildSuccess() { var state = Ready(); state.BeginBuild(); state.BuildSucceeded(); Assert.True(state.CanValidate); Assert.True(state.CanOpenDocx); Assert.True(state.CanOpenPdf); }
private static WorkbenchStateController Ready() { var state = new WorkbenchStateController(); state.InputsSelected(true); state.AnalysisCompleted(true, true, true); return state; }
}
@@ -0,0 +1,26 @@
using DocumentAutomation.Shared;
using StandardsWorkbench.Core;
namespace StandardsWorkbench.Tests;
public sealed class RulesEngineTests
{
[Fact]
public void OnlyApprovedRulesAreEvaluated()
{
var document = new DocumentInspectionResult { FilePath = "synthetic.docx", ContentControls = 1, Sections = 1, Paragraphs = 2, Tables = 0 };
var profile = new ApprovedRulesProfile { StandardId = "TEST", Code = "TEST", Revision = "1", Rules = [
new CandidateRule { CandidateId = "approved", StandardId = "TEST", ClauseId = "1", SourceText = "x", Title = "x", RuleKind = "ContentControlPresence", Status = "Approved" },
new CandidateRule { CandidateId = "new", StandardId = "TEST", ClauseId = "2", SourceText = "x", Title = "x", RuleKind = "SectionCount", Status = "New" }
], SourceFileSha256 = "test"};
var result = new RulesEngine().Evaluate(document, profile); Assert.Single(result); Assert.Equal(RuleEvaluationStatus.Pass, result[0].Status);
}
[Fact]
public void FailedRuleHasClauseReference()
{
var document = new DocumentInspectionResult { FilePath = "synthetic.docx", ContentControls = 0, Sections = 1, Paragraphs = 1, Tables = 0 };
var rule = new CandidateRule { CandidateId = "cc", StandardId = "TEST", ClauseId = "6.3", SourcePage = 4, SourceText = "required", Title = "cc", RuleKind = "ContentControlPresence", Status = "Tested" };
var result = Assert.Single(new RulesEngine().Evaluate(document, new ApprovedRulesProfile { StandardId = "TEST", Code = "TEST", Revision = "1", SourceFileSha256 = "test", Rules = [rule] })); Assert.Equal(RuleEvaluationStatus.Fail, result.Status); Assert.Equal("6.3", result.ClauseId); Assert.Equal(4, result.SourcePage);
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup><ProjectReference Include="..\..\src\StandardsWorkbench.Core\StandardsWorkbench.Core.csproj" /></ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
artifacts/
.local-dotnet-sdk/
.local-tools/
bin/
obj/
.vs/
+119
View File
@@ -0,0 +1,119 @@
# AGENTS.md — P_Rec_Annot
## Назначение и package
`P_Rec_Annot` — приоритетный Plant 3D 2026 plugin для чтения, планирования и
последующего создания Ortho-аннотаций; это перенос активного legacy поведения
Plant 3D 2019 на .NET 8/Plant 3D 2026.
Единственная assembly для `NETLOAD` — `PRecAnnot.Plant3D2026.dll`.
`PRecAnnot.*.dll` — internal managed dependencies, их нельзя NETLOAD по одной.
Использовать Autodesk 2026 references; Autodesk DLL не включать в bundle.
## Commands и context
Поддерживаемые команды: `PHP_REC_ANNOT`, `PXP-AAV`, `PXP-OA`. Все
регистрируются реальным Autodesk API, работают в Smoke/DryRun, не изменяют DWG
в этих режимах и создают одно usage event на явный вызов.
Project status: `NotFound`, `FoundWithName`, `FoundWithoutDisplayName`,
`Ambiguous`, `Failed`. Никогда не выводить `проект '' найден`; для пустого
display name: `Plant 3D-проект открыт; отображаемое имя не определено.`
Diagnostic code: `PLANT_PROJECT_DISPLAY_NAME_EMPTY`.
Drawing states: `Ortho`, `NotOrtho`, `Ambiguous`, `Failed`. Обычный DWG не
классифицируется как Ortho; для реального Ortho применять Autodesk API и
`OrthoDocUtils.IsValidOrthoDrawing`.
## DryRun и determinism
Pipeline: `Plant context → Ortho detection → sheet/view definitions → viewport
snapshots → DataLinks → Plant properties → independent DTO → deterministic
AnnotationPlan → diagnostics → no write`.
Autodesk objects, transactions и database handles остаются в adapter layer;
Core/Application используют только независимые DTO. На неизменном fixture
повторный DryRun обязан давать тот же PlanId, counts, порядок операций и warning
codes, без side effects. PlanId не включает время, GUID, absolute path,
нестабильный ObjectId или пользовательские данные.
## Write guard, Ribbon, tests
Все writes проходят централизованный write guard; в Smoke/DryRun он всегда
запрещает изменения. Apply не обходится через command handler/adapter.
Ожидаемый Ribbon путь: `Промхимпроект → Аннотации → Аннотировать`. Требуются
stable IDs, отсутствие дублей, binding к `PHP_REC_ANNOT`, устойчивость к
документам/workspace и корректный Terminate.
Unit tests не требуют Autodesk runtime. Runtime evidence отдельно фиксирует
initialization, commands, context, Ortho, DataLinks, plan, Ribbon, DBMOD,
telemetry и shutdown.
Bundle содержит только фактический runtime dependency graph и никогда не
содержит Autodesk DLL, PDB, test assemblies, legacy 2019 assemblies, bin/obj
или production configuration.
Fixture: local Plant 3D 2026 project вне Git с настоящей Pipe, synthetic line
number, Ortho drawing/viewport и SHA baseline до DryRun.
## Viewport membership and source DataLinks
Legacy P_Rec_Annot source for read-only analysis is
`\\phpsrv03\NBIM\code\CS\P_Rec_Annot\v1.0.4\repo`.
A source object is not a member of an Ortho viewport without confirmed
evidence. A geometry-only filter is at most `PartiallyResolved`. Apply is
blocked by any `PLUGIN_ONLY_CHANGE` in a DWG, DCF, or project metadata found by
a paired CONTROL/DryRun comparison.
## Ortho source DataLinks and DCF control
DataLinks активного Ortho drawing не являются источником Piping rows. Piping
rows читаются из source model drawing и Piping project part, разрешённых через
Ortho sheet/view definitions.
Перед предположениями об Autodesk runtime API необходимо исследовать текущий
код, доступный legacy usage, assembly metadata и реальные runtime types.
Reflection/runtime inspection применяется только для подтверждения данных,
которых нельзя установить статически. Production resolver не угадывает
Autodesk properties по строковым именам.
Подтверждённый контракт Plant 3D 2026: `IDwgViewDef.DwgQuery` →
`IDwgQuery.ModelFiles` → `ModelFile[].ModelFileName`. При несовпадении типа
возвращать `ORTHO_MODEL_FILES_API_UNSUPPORTED`, без fallback-эвристики.
Проверка DCF выполняется на парных `control` и `DryRun` fixtures, созданных из
одного закрытого состояния проекта. Сравнивать нужно `control delta` с
`DryRun delta`: одинаковые изменения классифицируются как baseline behavior
Plant 3D, а plugin-only изменения блокируют дальнейшую работу.
## Общая карта проекта
Каноническая общая карта проекта:
`https://drive.google.com/drive/folders/1avnR0V8q7y5pfWRYwB6LPBEEyfpN7PpV`.
Необязательная локальная копия доступна только через
`PHIM_PLANT3D_PROJECT_MAP_ROOT`; локальный путь синхронизации не хардкодить.
До изменения P_Rec_Annot читать доступные индекс карты, запись архитектуры и
статуса P_Rec_Annot, DryRun/Apply contract, карту Ortho/DataLinks и связанный
runtime evidence index. Сверять их с фактическим Git HEAD, текущими plugin
code/tests/configuration и runtime evidence конкретной fixture.
Приоритет источников: runtime evidence, затем code/tests/configuration на
проверенном HEAD, затем tracked repository documentation, затем общая карта.
Карта не является build/runtime dependency и не заменяет фактический код либо
runtime evidence. Недоступную карту отмечать `PROJECT_MAP_UNAVAILABLE`;
несовпадение её commit/runtime status с фактическими данными —
`PROJECT_MAP_STALE`.
Инварианты P_Rec_Annot: остаётся единственная NETLOAD entry assembly, aliases
и статусы команд не исчезают молча, Smoke/DryRun не пишут DWG, а Apply требует
явного разрешения, локальной fixture, ограниченной transaction, evidence и
проверяемого rollback/UNDO. Не ослаблять подтверждённый Ortho source/DataLinks
контракт и DCF control comparison ради сведений из карты.
После изменения plugin architecture или runtime-status в отчёте перечислять
документы карты, требующие обновления: запись P_Rec_Annot, command/status,
Ortho/DataLinks contract, DryRun/Apply/UNDO evidence и migration status. Не
утверждать обновление внешней документации без фактического изменения.
@@ -0,0 +1,17 @@
<Project>
<Import Project="Local.Build.props" Condition="Exists('Local.Build.props')" />
<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>
<Plant3D2026StandardInstallDir Condition="'$(Plant3D2026StandardInstallDir)' == ''">$(ProgramW6432)\Autodesk\AutoCAD 2026</Plant3D2026StandardInstallDir>
<AutoCAD2026ManagedDir Condition="'$(AutoCAD2026ManagedDir)' == '' and Exists('$(Plant3D2026StandardInstallDir)\AcMgd.dll')">$(Plant3D2026StandardInstallDir)</AutoCAD2026ManagedDir>
<Plant3D2026ManagedDir Condition="'$(Plant3D2026ManagedDir)' == '' and Exists('$(Plant3D2026StandardInstallDir)\PLNT3D\PnPProjectManagerMgd.dll')">$(Plant3D2026StandardInstallDir)\PLNT3D</Plant3D2026ManagedDir>
</PropertyGroup>
</Project>
+54
View File
@@ -0,0 +1,54 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Abstractions", "src\PRecAnnot.Abstractions\PRecAnnot.Abstractions.csproj", "{10000000-0000-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Core", "src\PRecAnnot.Core\PRecAnnot.Core.csproj", "{10000000-0000-0000-0000-000000000002}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Application", "src\PRecAnnot.Application\PRecAnnot.Application.csproj", "{10000000-0000-0000-0000-000000000003}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Plant3D2026", "src\PRecAnnot.Plant3D2026\PRecAnnot.Plant3D2026.csproj", "{10000000-0000-0000-0000-000000000004}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.UI", "src\PRecAnnot.UI\PRecAnnot.UI.csproj", "{10000000-0000-0000-0000-000000000005}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Telemetry", "src\PRecAnnot.Telemetry\PRecAnnot.Telemetry.csproj", "{10000000-0000-0000-0000-000000000006}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PRecAnnot.Core.Tests", "tests\PRecAnnot.Core.Tests\PRecAnnot.Core.Tests.csproj", "{10000000-0000-0000-0000-000000000007}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{10000000-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000006}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000006}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000006}.Release|Any CPU.Build.0 = Release|Any CPU
{10000000-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000000-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000000-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000000-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+24
View File
@@ -0,0 +1,24 @@
# P_Rec_Annot Plant 3D 2026 foundation
Foundation for migration from the legacy P_Rec_Annot v1.0.4. Legacy sources remain read-only and are not copied here.
Current slice:
* `PRecAnnot.Abstractions` — Autodesk-free immutable contracts and DTOs;
* `PRecAnnot.Core` — deterministic annotation planning and duplicate filtering;
* `PRecAnnot.Application` — command registry and use-case orchestration;
* `PRecAnnot.Plant3D2026` — guarded adapter boundary; compilation against Plant 3D 2026 is blocked until the SDK/runtime is installed;
* `PRecAnnot.UI` — idempotent Ribbon registration model, without legacy `_netload`;
* `PRecAnnot.Telemetry` — local JSONL command-scope events, without project path/number;
* `PRecAnnot.Bundle.Tests` and `PRecAnnot.Core.Tests` — architecture/core/packaging checks.
The first vertical slice is intentionally a dry-run planning slice. It does not mutate DWG until the Plant 3D 2026 adapter is compiled and runtime-tested on a controlled project.
Build:
```powershell
dotnet build .\PRecAnnot2026.sln --configuration Release
dotnet test .\PRecAnnot2026.sln --configuration Release --no-build
```
When Plant 3D 2026 is installed, pass `/p:Plant3D2026InstallDir=...`. Autodesk assemblies are `Private=false`, `CopyLocal=false` and are never committed.
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ApplicationPackage SchemaVersion="1.0" Name="P_Rec_Annot" Description="P_Rec_Annot Plant 3D 2026" AppVersion="0.2.19" ProductCode="b455ee6eaed79d76f084846a60ce3284" 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,22 @@
<?xml version="1.0" encoding="utf-8"?>
<Main>
<CopyMissingObjects>
<Object type="Block" name="BLOCK_ANNO_POSITION" />
<Object type="Block" name="BLOCK_ELEVATION" />
<Object type="Block" name="BLOCK_LINENUMBER_CONNECTION" />
<Object type="Layer" name="PXP_ANNOTATION" />
<Object type="Layer" name="PXP_AXIS" />
<Object type="TextStyle" name="GOSTC" />
</CopyMissingObjects>
<LayersSetup>
<LayerSetup filtertype="Status" filtervalue="New" color="" lineweight="60" linetype="Continuous" />
<LayerSetup filtertype="Status" filtervalue="Existing" color="" lineweight="-3" linetype="Continuous" />
<LayerSetup filtertype="Status" filtervalue="Reserved" color="" lineweight="-3" linetype="PXP_DASH" />
<LayerSetup filtertype="Status" filtervalue="Demolition" color="" lineweight="-3" linetype="PXP_DASH" />
<LayerSetup filtertype="Layer" filtervalue="CENTER" color="4" lineweight="-3" linetype="PXP_AXIS" />
</LayersSetup>
<Annotations layer="PXP_ANNOTATION" multileaderstyle="PXP_MLEADER" textstyle="GOSTC" />
<LinenumberConnections polylinelayer="PXP_AXIS" textlayer="PXP_TEXT" textstyle="GOSTC" />
<Elevations layer="PXP_THIN" textstyle="GOSTC" />
<SetLayer name="PXP_AXIS" />
</Main>
@@ -0,0 +1,5 @@
# Annotation-plan determinism
`AnnotationPlanBuilder` uses schema version `1`. Its canonical input is the normalized command, source drawing basename, view ID, source snapshots sorted by stable ID, properties sorted by key, membership state, and operations sorted by stable source key and type. Absolute paths, process data, timestamps and runtime ObjectIds are excluded.
Runtime DryRun confirmed equal PlanIds and summaries for two calls of each supported command. Changing a relevant normalized input changes the PlanId.
@@ -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.
+35
View File
@@ -0,0 +1,35 @@
# Build
References resolve in this order: explicit `AutoCAD2026ManagedDir` and
`Plant3D2026ManagedDir`, an untracked `Local.Build.props`, then the validated
standard Plant 3D 2026 install directory. No Autodesk binary is copied into the
repository or bundle. If discovery does not find all required assemblies, pass
the explicit properties shown below.
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,26 @@
# CONTROL versus DryRun DCF comparison
DBMOD covers only the active drawing. This procedure separately compares
content-bearing project files after identical CONTROL and DryRun sessions.
1. With Plant 3D closed and the Launcher session `RolledBack`, copy the local
fixture to `<FROZEN_FIXTURE>`.
2. Create `<CONTROL_FIXTURE>` and `<DRYRUN_FIXTURE>` from that same frozen
state; calculate complete local SHA-256 inventories before each run.
3. Open model and Ortho drawings in CONTROL through the same Launcher DryRun
launch contract, but invoke no P_Rec_Annot command. Close without saving,
wait for Launcher rollback, then calculate the after inventory.
4. Repeat with DRYRUN and invoke each supported command twice. Close without
saving, wait for rollback, then calculate the after inventory.
5. Compare deltas by logical relative path. Classify each content-bearing file
as `UNCHANGED`, `AUTODESK_BASELINE_CHANGE`, `PLUGIN_ONLY_CHANGE`, or
`INCONCLUSIVE`.
Content-bearing groups include model DWG, Ortho DWG, `Piping.dcf`, remaining
DCF/database files, `Project.xml` or equivalent project metadata, and Ortho
metadata. Lock, cache, session, log, preview, and temporary files are reported
separately and cannot establish project invariance.
Any `PLUGIN_ONLY_CHANGE` blocks Apply. Full paths and hashes remain local;
repository evidence contains only logical fixture markers, groups, counts, and
classifications.
@@ -0,0 +1,19 @@
{
"candidate": "P_Rec_Annot 0.2.19",
"root": "<TEST_FIXTURE_ROOT>/controlled-fixtures-0.2.19",
"sourcePolicy": "copied only from approved controlled PRecAnnotOrthoDryRun/FilesRelease fixtures; no production data",
"families": [
{"id":"line-number","artifacts":["line-number/TestPipeOrtho.dwg","line-number/TestPipeModel.dwg","line-number/Project.xml","line-number/Piping.dcf"],"status":"MATERIALIZED; OWNED_HANDLE_DISCOVERY_REQUIRED","workflows":["PXP-LN","PXP-ULN","PXP-ESN","PXP-ELN","PXP-RMP"]},
{"id":"elevation","artifacts":["elevation/TestPipeOrtho.dwg","elevation/TestPipeModel.dwg","elevation/Project.xml","elevation/Piping.dcf"],"status":"MATERIALIZED; SOURCE_ELEVATION_CASES_REQUIRED","workflows":["PXP-AE","PXP-IE"]},
{"id":"ortho","artifacts":["ortho/TestPipeOrtho.dwg","ortho/TestPipeModel.dwg","ortho/Project.xml","ortho/Piping.dcf"],"status":"MATERIALIZED; EXACT_VIEWPORT_MAPPING_REQUIRED","workflows":["PXP-AAV","PXP-COV","PXP-UOB","PXP-DJ"]},
{"id":"manual","artifacts":["manual/Template.dwg"],"status":"MATERIALIZED; MANUAL_STATE_AND_OWNED_BLOCK_REQUIRED","workflows":["mto-ma","PXP-MA","CAC","CAC1","BJ","PXP-MAB","PXP-AC"]},
{"id":"batch","artifacts":["batch/FolderA/SameName.dwg","batch/FolderB/SameName.dwg"],"status":"MATERIALIZED; CONTROLLED_PROJECT_RELATIVE_IDENTITY_REQUIRED","workflows":["PXP-IOD"]}
],
"hashes": {
"TestPipeOrtho.dwg": "B372BB94B039AB70E0721F2403D36065588C099B101360BE916C74F55B4B7A39",
"TestPipeModel.dwg": "D702E6E1BF100F6BB2D5623128ECB8E8685398FEFD162A989A1574404C1EA8BF",
"Project.xml": "EC622CF25E31FDCFEA009581880DA74DC5213EC8427767AB1310E715E2C4F231",
"Piping.dcf": "F28A09832CDF647722E81F6CC38A632EC576E8B921E68B01D5335421FF20CE36",
"Template.dwg": "22CAAD35CE9C55891CF91042ED9B2C23BA4D5A0745AA40D69F41C1F609DAADB3"
}
}
@@ -0,0 +1,73 @@
{
"schemaVersion": 1,
"policy": "controlled-local-only; fail-closed on ambiguous identity; no production DWG/project writes",
"fixtures": [
{
"id": "LINE_NUMBER_ORTHO_OWNED",
"requiredDocument": "controlled Plant Ortho DWG copy",
"requiredObjects": ["Pipe with DataLinks line number", "owned line-number block", "legacy layers/text styles"],
"selection": "exact owned block handle discovered from drawing",
"preState": "owned annotation baseline and unchanged Plant source",
"mutation": "create/update/delete line annotation",
"repeat": "update or AlreadyExists according to command",
"undo": "native AutoCAD UNDO restores entity and ownership baseline",
"workflows": ["PXP-LN", "PXP-ULN", "PXP-ESN", "PXP-ELN", "PXP-RMP"]
},
{
"id": "CONNECTOR_TOPOLOGY",
"requiredDocument": "controlled Plant project and Ortho copy",
"requiredObjects": ["two connected Plant parts", "connector/port snapshot", "P3dPartConnection", "line-group relationships"],
"selection": "exact connector source identity; no arbitrary entity fallback",
"preState": "no owned connection annotation",
"mutation": "orthogonal connection leader and ownership XData",
"repeat": "recreate/update without duplicate ownership",
"undo": "native AutoCAD UNDO restores pre-annotation state",
"workflows": ["PXP-ALC"]
},
{
"id": "ELEVATION_GOLDEN",
"requiredDocument": "controlled Plant model plus paper/model context",
"requiredObjects": ["positive, negative and zero elevations", "BLOCK_ELEVATION definition", "attribute definitions"],
"selection": "exact Plant source object handle",
"preState": "source object and annotation baseline",
"mutation": "legacy /5, AwayFromZero, *5 elevation annotation",
"repeat": "update existing owned annotation",
"undo": "native AutoCAD UNDO restores baseline",
"workflows": ["PXP-AE", "PXP-IE"]
},
{
"id": "ORTHO_VIEWPORT_EXACT",
"requiredDocument": "controlled Plant Ortho DWG with one-to-one source view mapping",
"requiredObjects": ["exact viewport handle/ObjectId", "layout membership", "Ortho view identity", "visible Plant candidates"],
"selection": "validated AcDbViewport handle and expected Ortho mapping",
"preState": "viewport and annotation baseline",
"mutation": "view annotation/viewport maintenance operation",
"repeat": "deterministic update/no duplicate",
"undo": "native AutoCAD UNDO restores viewport/entity baseline",
"workflows": ["PXP-AAV", "PXP-COV", "PXP-UOB", "PXP-DJ"],
"status": "EXACT_VIEWPORT_FIXTURE_BLOCKED"
},
{
"id": "MANUAL_TEMPLATE_EDIT",
"requiredDocument": "controlled local Template-derived drawing",
"requiredObjects": ["manual annotation blocks", "attributes", "dynamic properties", "required layers/styles", "owned P_REC_ANNOT_MANUAL XData"],
"selection": "exact owned annotation handle for edit/reopen",
"preState": "palette state and owned annotation baseline",
"mutation": "manual insert/update/attribute persistence",
"repeat": "restore and update existing owned annotation",
"undo": "native AutoCAD UNDO restores entity and manual-state baseline",
"workflows": ["mto-ma", "PXP-MA", "CAC", "CAC1", "BJ", "PXP-MAB", "PXP-AC"]
},
{
"id": "ORTHO_BATCH_SAME_NAME",
"requiredDocument": "controlled project copy with two relative drawing paths",
"requiredObjects": ["same filename in different project-relative directories", "one failing controlled drawing"],
"selection": "project-relative identity, never filename-only",
"preState": "unchanged controlled copies",
"mutation": "per-drawing update with continue-on-error",
"repeat": "deterministic ordering and idempotent result",
"undo": "per-drawing recovery/cleanup",
"workflows": ["PXP-IOD"]
}
]
}
@@ -0,0 +1,17 @@
{
"executionMode": "DryRun",
"planSchemaVersion": 1,
"sourceObjectsRead": 7,
"membershipResolved": 0,
"membershipPartial": 0,
"membershipUnresolved": 7,
"phpRecAnnotPlanIds": ["plan-C5D40DC32505D5D9CFE6D38B", "plan-C5D40DC32505D5D9CFE6D38B"],
"pxpAavPlanIds": ["plan-E4877CA13418D43A17F47AAA", "plan-E4877CA13418D43A17F47AAA"],
"pxpOaPlanIds": ["plan-6C794D25C292E73AA3BDDC31", "plan-6C794D25C292E73AA3BDDC31"],
"summariesDeterministic": true,
"dbmodBefore": 0,
"dbmodAfter": 0,
"launcherSession": "RolledBack",
"dcfControlComparison": null,
"applyModeUsed": false
}
@@ -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,66 @@
{
"schemaVersion": 1,
"executionMode": "DryRun",
"fixture": {
"frozen": "<FROZEN_FIXTURE>",
"control": "<CONTROL_FIXTURE>",
"dryRun": "<DRYRUN_FIXTURE>",
"trackedFileCount": 210
},
"membership": {
"mechanism": "IDwgViewDef.DwgQuery.Execute -> MdObjectId.PrjObjectId -> DataLinksManager.FindAcPpRowId",
"orthoViews": 1,
"sourceObjects": 7,
"resolved": 5,
"partiallyResolved": 0,
"outsideView": 2,
"notResolved": 0,
"evidenceCode": "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID"
},
"commands": {
"PHP_REC_ANNOT": {
"planSchemaVersion": 1,
"run1PlanId": "plan-AD701C7AA38D99E4C28D7D6D",
"run2PlanId": "plan-AD701C7AA38D99E4C28D7D6D",
"planDeterministic": true,
"summaryDeterministic": true
},
"PXP-AAV": {
"planSchemaVersion": 1,
"run1PlanId": "plan-C4FCD62F4EEBA1D1948FA22B",
"run2PlanId": "plan-C4FCD62F4EEBA1D1948FA22B",
"planDeterministic": true,
"summaryDeterministic": true
},
"PXP-OA": {
"planSchemaVersion": 1,
"run1PlanId": "plan-C30D133E2D5B4BF4C0AF2ED1",
"run2PlanId": "plan-C30D133E2D5B4BF4C0AF2ED1",
"planDeterministic": true,
"summaryDeterministic": true
}
},
"projectFileComparison": {
"controlDeltaCount": 0,
"dryRunDeltaCount": 0,
"modelDwgPluginOnlyDelta": "UNCHANGED",
"orthoDwgPluginOnlyDelta": "UNCHANGED",
"pipingDcfPluginOnlyDelta": "UNCHANGED",
"projectMetadataPluginOnlyDelta": "UNCHANGED"
},
"dbmod": {
"pairedRun": null,
"reason": "Command-window output was not retained."
},
"launcher": {
"controlSession": "RolledBack",
"dryRunSession": "RolledBack",
"netloadCount": 1,
"profileArgumentPresent": false
},
"apply": {
"performed": false,
"authorised": false,
"reason": "All operations remain Skip; annotation rules are not yet implemented."
}
}
@@ -0,0 +1,28 @@
{
"date": "2026-08-03",
"executionMode": "DryRun",
"plantProjectDetected": true,
"projectDisplayNameStatus": "FoundWithName",
"orthoDetection": "Confirmed",
"viewportCount": 1,
"dataLinksAvailable": true,
"dataLinksSheetToSourceMapping": "resolved-through-piping-project-drawing-id",
"sourceDrawingsResolved": 1,
"sourceDataLinksAvailable": true,
"objectsRead": 7,
"pipeObjectsRead": 7,
"phpRecAnnotRuns": 1,
"pxpAavRuns": null,
"pxpOaRuns": null,
"planId": null,
"repeatedPlanIdEqual": null,
"dbmodBefore": null,
"dbmodAfter": null,
"dwgModified": null,
"dcfModified": null,
"changedProjectFiles": null,
"unhandledExceptions": 0,
"applyModeUsed": false,
"launcherRollback": "RolledBack",
"exactViewportMembership": "not-verified"
}
@@ -0,0 +1,26 @@
{
"product": "Autodesk AutoCAD Plant 3D 2026",
"manualNetload": true,
"bundleLoaded": true,
"automaticAppLoaderChecked": false,
"smokeModeConfirmedByOperator": true,
"observedCommandLogRuntimeMode": "Normal",
"assemblyInitializeCountObserved": 1,
"commands": {
"PHP_REC_ANNOT": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" },
"PXP-AAV": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" },
"PXP-OA": { "registered": true, "executed": true, "usageEvents": 1, "result": "PreflightAccepted" }
},
"ribbon": { "created": true, "duplicateCheckedExhaustively": false },
"plantContext": { "projectDetected": true, "projectDisplayName": "DefaultProject", "observedOrthoDetected": false, "dataLinksAvailable": true },
"writesBlocked": true,
"dbmodChecked": false,
"dwgModified": false,
"dcfModified": false,
"projectXmlModified": false,
"telemetryEventsExpected": 3,
"telemetryEventsActual": 3,
"unhandledExceptions": 0,
"temporaryInstallationRemoved": true,
"productionDeploy": false
}
@@ -0,0 +1,3 @@
# Golden test plan
Capture a minimal 2019 Ortho fixture with input objects, output blocks/attributes, text, layer, style, coordinates, rotation, scale, DataLinks, before/after usage event and repeat-run behavior. Recreate the same fixture in 2026 only after the adapter is available. Do not commit production DWG/DCF.
@@ -0,0 +1,5 @@
RuleId,TargetClass,TargetMethod,LegacyFile,LegacyLines,Behavior,MigrationDecision,TestName
RULE-001,AnnotationRuleEngine,BuildPlan,AutoAnnotateView/Commands.cs,476-500,Remove prior annotations then insert calculated annotations,REIMPLEMENT_SAME_BEHAVIOR,ExistingAnnotation_IsUpdatedNotDuplicated
RULE-002,AnnotationRuleEngine,BuildPlan,OrthoAutoAnnotate/Commands.cs,6444-6488,Require Plant project and registered Ortho drawing,KEEP_AFTER_ISOLATION,PlantProjectAndOrthoDrawingRequired
RULE-003,PlantObjectReader,ReadObjects,README.md,96-101,Read EngineeringItems and line-group relationships through PnP API,REIMPLEMENT_SAME_BEHAVIOR,PlantAdapter_ReadsGoldenObjects
RULE-004,CommandRegistry,Foundation,OrthoLoader/Ribbon.cs,109-129,Expose annotation commands in annotation panel,REIMPLEMENT_SAME_BEHAVIOR,AllActiveCommands_AreMapped
1 RuleId TargetClass TargetMethod LegacyFile LegacyLines Behavior MigrationDecision TestName
2 RULE-001 AnnotationRuleEngine BuildPlan AutoAnnotateView/Commands.cs 476-500 Remove prior annotations then insert calculated annotations REIMPLEMENT_SAME_BEHAVIOR ExistingAnnotation_IsUpdatedNotDuplicated
3 RULE-002 AnnotationRuleEngine BuildPlan OrthoAutoAnnotate/Commands.cs 6444-6488 Require Plant project and registered Ortho drawing KEEP_AFTER_ISOLATION PlantProjectAndOrthoDrawingRequired
4 RULE-003 PlantObjectReader ReadObjects README.md 96-101 Read EngineeringItems and line-group relationships through PnP API REIMPLEMENT_SAME_BEHAVIOR PlantAdapter_ReadsGoldenObjects
5 RULE-004 CommandRegistry Foundation OrthoLoader/Ribbon.cs 109-129 Expose annotation commands in annotation panel REIMPLEMENT_SAME_BEHAVIOR AllActiveCommands_AreMapped
@@ -0,0 +1,18 @@
# Migration dashboard
The first real Plant 3D 2026 build is complete against the workstation assemblies (AutoCAD managed 25.1.0.0; Plant managed 17.0.60.1; AdWindows 5.2.1.1). Core remains Autodesk-free.
| Area | Status |
|---|---|
| `PHP_REC_ANNOT` | Registered real `CommandMethod`; primary diagnostic entry point |
| `PXP-AAV` | Registered real `CommandMethod`; diagnostic alias |
| `PXP-OA` | Registered real `CommandMethod`; diagnostic alias |
| Ribbon | Implemented with `Autodesk.Windows`; idempotent tab/panel guard |
| Adapter | Compiled against real 2026 assemblies |
| Bundle | Local smoke package built and checked for Autodesk DLL exclusion |
| Runtime smoke | Manual NETLOAD confirmed on Plant 3D 2026; automatic AppLoader and Ortho DryRun remain unverified |
| Ortho read path | Compiled against `OrthoDocUtils`, `OrthoProject`, `DwgSheetDefinitions`, `DwgViewDefs`, and `DataLinksManager`; runtime fixture pending |
| Dry-run plan | Deterministic Core builder with DTO boundary and Apply guard; no annotation writes |
| Production deploy | NOT PERFORMED |
Fixed API assumptions: foundation-only lifecycle became `IExtensionApplication`; command descriptors became actual `CommandMethod` registrations; Ortho detection uses `OrthoDocUtils.IsValidOrthoDrawing` plus the Ortho project part; viewport metadata uses `DwgSheetDefinitions`/`DwgViewDefs`; object properties use read-only `DataLinksManager.SelectAcPpRowIds` and `GetAllProperties`; Core receives only immutable DTOs. Remaining blockers are a controlled Ortho fixture, geometry/object-to-viewport mapping, legacy source provenance, and future Apply implementation.
@@ -0,0 +1,15 @@
# Module migration matrix
| Legacy module | Target project | Classification | Wave |
|---|---|---|---|
| AnnotateLineNumbers | PRecAnnot.Core + Plant3D2026 | KEEP_AFTER_ISOLATION | 3 |
| AutoAnnotateView | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 1 |
| OrthoAutoAnnotate | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 1 |
| AutoLinenumberConnections | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 2 |
| AutoElevation/InsertElevation | Core + Plant3D2026 | REIMPLEMENT_SAME_BEHAVIOR | 3 |
| UpdateOrthoDrawings | Plant3D2026/Application | DEFER_AND_RETEST | 5 |
| OrthoLoader | bundle/entry composition root | DO_NOT_REUSE | 1 |
| Ribbon | PRecAnnot.UI | REIMPLEMENT | 1 |
| GetLinenumberAnnotationText | Plant adapter + Core mapper | KEEP_AFTER_ISOLATION | 2 |
| PHPAnnotations/P3DNewMa | UI + block adapter | REVIEW_USAGE | 4 |
| telemetry | PRecAnnot.Telemetry | REIMPLEMENT | 1 |
@@ -0,0 +1,46 @@
# Ortho DryRun runtime result
Date: 2026-08-03
The local test fixture's `TestPipeOrtho.dwg` was detected as Ortho with one
viewport. In the paired `DryRun`, `PHP_REC_ANNOT`, `PXP-AAV`, and `PXP-OA`
each ran twice. Plugin telemetry recorded all six preflight completions without
an unhandled exception. The command-window DBMOD output was not retained for
this paired run, so its DBMOD value is recorded as `NOT RECORDED` rather than
inferred from telemetry.
`PlantProject.Name` was available for this fixture and rendered as
`PRecAnnotTestProject`.
The active Ortho sheet does not expose direct Piping rows and must not be used
as a DataLinks source. Metadata inspection and a sanitized runtime probe
confirmed `IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName` as the source-model
reference. The adapter resolves that reference through
`PipingProject.GetPnPDrawingFiles()` and reads
`DataLinksManager.SelectAcPpRowIds(PnPProjectDrawing.Id)`.
On the paired real DryRun, one source drawing was resolved and seven source
Plant objects were read. The confirmed view query resolved five source rows to
the active viewport and classified two as outside the view. Each command
reported seven skipped operations, zero writes, zero conflicts, and zero
errors. The repeated plans were deterministic:
| Command | Run 1 / run 2 PlanId |
| --- | --- |
| `PHP_REC_ANNOT` | `plan-AD701C7AA38D99E4C28D7D6D` |
| `PXP-AAV` | `plan-C4FCD62F4EEBA1D1948FA22B` |
| `PXP-OA` | `plan-C30D133E2D5B4BF4C0AF2ED1` |
This verifies source-model DataLinks access, exact view membership through the
stored view query, and repeated-plan determinism. The remaining functional
limitation is annotation-rule coverage: all seven operations remain safe
`Skip` operations, so no first Apply is authorised.
The earlier single-fixture SHA baseline changed before the first command and
was therefore inconclusive. A fresh paired comparison used 210-file CONTROL
and DRYRUN copies created from the same closed frozen fixture. Both inventories
had zero SHA-256 delta after their respective sessions. The CONTROL session
invoked no plugin command; the DRYRUN session invoked all six commands. Thus
the model DWG, Ortho DWG, `Piping.dcf`, project metadata, and other tracked
content-bearing files have no plugin-only delta in this comparison. Both
Launcher sessions completed automatic rollback to `RolledBack`.
@@ -0,0 +1,39 @@
# Ortho source-model mapping
## Confirmed Plant 3D 2026 read path
```text
Active Ortho document
→ OrthoProject.DwgSheetDefinitions
→ IDwgSheetDef.DwgViewDefs
→ IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName
→ PipingProject.GetPnPDrawingFiles()
→ exact normalized-path match to PnPProjectDrawing
→ PnPProjectDrawing.Id
→ DataLinksManager.SelectAcPpRowIds(int dwgid)
→ GetObjectClassname / GetAllProperties
→ independent PlantObjectSnapshot
→ AnnotationPlanBuilder
```
`PlantOrthoViewportReader.ReadViewports` reads the typed DwgQuery contract and
creates an Ortho viewport snapshot. `PlantObjectReader.ReadObjects` resolves
the resulting model-file reference through the Piping project drawing list and
reads DataLinks by project drawing ID. The active Ortho database is not a
Piping-row source.
All calls are read-only. The adapter opens no write transaction and does not
save a drawing, DCF, or project metadata.
## Diagnostics
The source reader returns stable telemetry diagnostics rather than suppressing
the result of a failed query: `ORTHO_SOURCE_DRAWING_NOT_RESOLVED`,
`ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT`, `ORTHO_SOURCE_DRAWING_AMBIGUOUS`,
`PIPING_PROJECT_PART_NOT_FOUND`, `SOURCE_DATALINKS_UNAVAILABLE`,
`SOURCE_DATALINKS_NO_ROWS`, `SOURCE_DATABASE_READ_FAILED`,
`SOURCE_PIPE_ROWS_NOT_FOUND`, or `SOURCE_DATALINKS_READ`.
Exact membership of a source object in a particular Ortho viewport is not yet
implemented. A source-level DryRun plan must not be presented as proof of
viewport membership.
@@ -0,0 +1,36 @@
# Ortho viewport membership
## Runtime path
`EntryPoint.RunDiagnostic` reads each `OrthoViewportInfo` and calls
`PlantObjectReader.ReadObjects`. The reader resolves source Piping rows, then
`PlantViewportMembershipReader.Read` evaluates the stored query for that exact
view. It maps `MdObjectId.PrjObjectId` to a source DataLinks row and assigns a
membership status to every independent `PlantObjectSnapshot`.
| Status | Meaning | Diagnostic evidence |
| --- | --- | --- |
| `Resolved` | The source row was returned by the exact view query. | `VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID` |
| `ObjectOutsideView` | The source row was not returned by that confirmed query. | `VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP` |
| `NotResolved` | The view contract is unavailable, ambiguous, or failed. | `VIEW_MEMBERSHIP_API_UNSUPPORTED` |
No source object is treated as visible solely because it belongs to the same
model drawing. No geometry-only result is upgraded to `Resolved`.
## Plan effect
`AnnotationPlanBuilder` includes membership status in its canonical plan input.
`ObjectOutsideView`, unsupported, and unresolved objects are `Skip` operations
with stable reason codes. `Resolved` membership can progress only through the
existing annotation rules; this DryRun slice never writes an annotation.
## Paired DryRun evidence
The paired local DryRun resolved five of seven source rows through the exact
view query and classified the remaining two as `ObjectOutsideView`. Each of
the three supported commands produced an identical PlanId on its repeated
invocation. Both Launcher sessions reached `RolledBack`.
The paired command-window DBMOD output was not retained. It is intentionally
reported as `NOT RECORDED`; project-file SHA-256 inventories nevertheless had
zero delta for both CONTROL and DRYRUN copies.
@@ -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,52 @@
# Release asset mapping
This is the release-to-composite contract for the owner-provided `P_Rec_Annot
v1.0.4.2` payload. It deliberately does not copy legacy binaries or a DWG into
source control. Autodesk assemblies, legacy plugin DLLs, Plant project data,
and the controlled `Template.dwg` fixture are excluded by repository policy.
## Executable payload
| Legacy release artifact | Modern functional replacement | Package disposition |
| --- | --- | --- |
| `AnnotateLineNumbers.dll`, `GetLinenumberAnnotationText.dll` | `PRecAnnot.Core`, `PRecAnnot.Application`, `PRecAnnot.Plant3D2026` line-number services | Replaced by composite assemblies |
| `AutoLinenumberConnections.dll` | connector snapshots, connection planner and Plant adapter | Replaced by composite assemblies |
| `AutoElevation.dll`, `InsertElevation.dll` | elevation formatter, planner and Plant mutation adapters | Replaced by composite assemblies |
| `AutoAnnotateView.dll`, `OrthoAutoAnnotate.dll` | modern Ortho annotation commands and adapters | Replaced by composite assemblies |
| `CheckOrthoView.dll`, `UpdateOrthoViewBlocks.dll`, `P3DCopyMissingObjectsToOrthoDrawing.dll`, `P3DSetLayerForViewport.dll`, `UpdateOrthoDrawings.dll` | modern Ortho maintenance commands and adapters | Replaced by composite assemblies |
| `GetComponentGeometry.dll`, `ExtentsIntersect.dll` | `PRecAnnot.Core` geometry contracts and Plant geometry reader | Replaced by composite assemblies |
| `P3DNewMa.dll`, `PHPAnnotations.dll`, `SwapAttributesMenuItem.dll` | `PRecAnnot.UI` palette and manual annotation commands | Replaced by composite assemblies |
| `GetPlantScriptFolderPath.dll`, `GetScriptParameters.dll` | typed portable settings/path contract | Replaced by composite assemblies |
| `OrthoLoader.dll`, `LoadedAssemblies.dll`, `EnablerLoader.dll` | composite bootstrap dependency order and capability checks | Replaced by the single entry assembly; legacy loaders never ship or execute |
The package file manifest generated by `tools/Build-Bundle.ps1` records SHA-256
for every package file. `tools/Validate-Bundle.ps1` verifies that manifest
before materialization.
## Configuration and named-object contract
| Artifact | Modern consumer / role | Missing-resource behavior |
| --- | --- | --- |
| `Settings.xml` | `bundle/P_Rec_Annot.bundle/Settings.xml`, parsed by `ProductionSettingsContract` and feature-specific settings readers | Command preflight reports configuration failure; no mutation occurs |
| `Template.dwg` | Controlled local fixture/template source for line-number, connection, elevation, Ortho and manual block/style contracts | Never silently depend on an arbitrary drawing. Commands report the required block/style as unavailable and make no mutation |
| block definitions and attribute tags | Resolved by the respective annotation/manual command before transaction | Preflight failure; no partial creation |
| layers, text styles, MLeader styles, dimension styles | Resolved by feature settings and the controlled template contract | Preflight failure; no partial mutation |
| dynamic block properties | Read and updated only by the manual annotation workflow when the selected block exposes them | Preserve existing values when absent; do not synthesize properties |
`Template.dwg` has release SHA-256
`22CAAD35CE9C55891CF91042ED9B2C23BA4D5A0745AA40D69F41C1F609DAADB3`.
It must be supplied through the controlled fixture provisioner, not committed or
bundled as a production drawing.
## User-facing resources
| Release artifacts | Consumer | Mapping state |
| --- | --- | --- |
| `ico/button_*.png` (20 files) | Legacy Ribbon/palette imagery | Source inventory retained; modern Ribbon currently uses command bindings without those binary images. Image packaging requires an approved non-binary resource import and explicit UI runtime evidence. |
| `Справка по работе с палитрой.docx` and `.pdf` | Manual annotation help surface | Documentation assets are retained in the owner release. The modern palette exposes no fabricated local copy; help links must resolve from a package-approved documentation asset before UI runtime verification. |
| `GetComponentGeometry.xml` | Legacy assembly XML documentation | Functionally replaced by modern source/API documentation; it is not a runtime dependency. |
The two rows marked as requiring approved resource import are intentionally not
classified as packaged. They remain explicit release-asset and UI-runtime gaps,
rather than being hidden by copying legacy content or claiming unverified
resource behavior.
@@ -0,0 +1,101 @@
# Plant 3D 2026 DwgQuery / ModelFiles API contract
## Scope and method
This report is based on metadata-only inspection of the installed Plant 3D 2026
assemblies. The assemblies were read from `<PLANT3D_2026_INSTALL>` and were not
copied into the repository. Metadata-only inspection was necessary because the
normal PowerShell host cannot load the product's .NET 8 dependencies safely.
Legacy source at the documented local path was not present on this machine, so
there is no legacy API claim in this report.
## Confirmed assemblies
| Assembly | Version | Relevant type |
| --- | --- | --- |
| `PnPDwg2dDef` | `0.0.0.0` | `Autodesk.ProcessPower.Drawings2d.DwgQuery`, `IDwgQuery`, `DwgViewDef`, `IDwgViewDef`, `DwgSheetDef`, `IDwgSheetDef` |
| `PnPDwg2dUtil` | `0.0.0.0` | `Autodesk.ProcessPower.Drawings2d.ModelFile` |
| `PnP3dOrthoProjectPart` | `17.0.60.1` | Ortho project-part integration assembly; the metadata scan found no public DwgQuery declaration there |
`PnPDwg2dDef` and `PnPDwg2dUtil` use `0.0.0.0` assembly versions in the
installed metadata. The supported runtime contract is therefore identified by
the exact public type names below, not by an invented product-version number.
## Confirmed contract
```text
Autodesk.ProcessPower.Drawings2d.IDwgViewDef.DwgQuery
: Autodesk.ProcessPower.Drawings2d.IDwgQuery
Autodesk.ProcessPower.Drawings2d.IDwgQuery.ModelFiles
: Autodesk.ProcessPower.Drawings2d.ModelFile[]
Autodesk.ProcessPower.Drawings2d.ModelFile.ModelFileName
: System.String
Autodesk.ProcessPower.Drawings2d.ModelFile.ModelFileGuid
: System.String
```
`DwgViewDef` implements `IDwgViewDef`; `DwgQuery` implements `IDwgQuery` and
`IDisposable`; `ModelFile` implements `IComparable<ModelFile>`.
`ModelFiles` is an array, not an untyped enumerable. `ModelFileName` is the
only confirmed string field representing the model-file reference. `ModelFileGuid`
is a separate identity field. `IsReferencedDwg` and `Xform` describe reference
state and transform, not a substitute path.
## Public members relevant to source resolution
`IDwgViewDef` exposes `DwgQuery`, `ViewName`, layout/orientation properties,
and `SetClippingPlanes`, `SetLayout`, `SetOrientation`, `Erase`, and `New`.
`IDwgQuery` exposes `ModelFiles`, `Execute`, and `Erase`.
`ModelFile` exposes `ModelFileName`, `ModelFileGuid`, `IsReferencedDwg`, and
`Xform`; its constructors accept a file-name string and optional transform,
reference flag, and GUID.
`IDwgSheetDef.DwgViewDefs` exposes the view-definition collection. Sheet
`DwgName`/`DwgPath` identify the Ortho sheet and must not be used as the source
of Piping DataLinks.
## Current and legacy usage
The original adapter used reflection and tried several possible names
(`ModelFileName`, `Value`, and arbitrary string values). That was not a valid
Plant 3D 2026 contract.
The production read path now uses the confirmed typed chain only:
```text
IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName
```
The legacy source path was unavailable locally, so no legacy source-model or
DataLinks workaround has been imported.
## Runtime evidence
Static metadata cannot prove whether `ModelFileName` is project-relative or
absolute in a concrete Ortho fixture, or whether a particular view has zero,
one, or multiple model files. In DryRun only, setting
`P_REC_ANNOT_DIAGNOSTICS=OrthoStructure` enables a sanitized structure probe.
It records type names, collection counts, null state, string length, and a
one-way value hash; it does not emit paths or engineering data.
On the local DryRun fixture, the runtime probe recorded one view with one
`ModelFile`. The concrete types were `DwgViewDef`, `DwgQueryByBox`, and
`ModelFile[]`; `ModelFileName` was non-null and an absolute fixture-model
reference. The path itself was not retained in repository evidence.
The resolver resolves that reference to exactly one
`PipingProject.GetPnPDrawingFiles()` entry by normalized full-path equality,
then uses the confirmed `DataLinksManager.SelectAcPpRowIds(int dwgid)` overload
with the resolved `PnPProjectDrawing.Id`. It does not open or query the active
Ortho database for Piping rows.
The production resolver has no fallback property-name search. If the exact
contract is unavailable at runtime it reports
`ORTHO_MODEL_FILES_API_UNSUPPORTED` rather than guessing.
@@ -0,0 +1,56 @@
# Ortho viewport membership API
## Scope
This report records the Plant 3D 2026 contract used to map source-model
DataLinks objects to one Ortho view. Autodesk binaries were inspected in
`<PLANT3D_2026_INSTALL>` and are not part of this repository.
## Confirmed Plant 3D 2026 contract
| Assembly | Version | Type/member | Purpose |
| --- | --- | --- | --- |
| `PnPDwg2dDef` | `0.0.0.0` | `IDwgViewDef.DwgQuery` | Returns the query stored by an Ortho view. |
| `PnPDwg2dDef` | `0.0.0.0` | `DwgQueryByBox.Execute(StringCollection, StringCollection)` | Executes the stored view selection and returns `IDwgSet`. |
| `PnPDwg2dDef` | `0.0.0.0` | `IDwgSet.GetItem(int, ref MdObjectId)` | Enumerates selected source identities. |
| `PnPDwg2dUtil` | `0.0.0.0` | `MdObjectId.PrjObjectId` | Exposes the selected project object identity. |
| `PnPDataLinks` | `17.0.60.1` | `DataLinksManager.FindAcPpRowId(PpObjectId)` | Maps a selected project object to its DataLinks row. |
`DwgQueryByBox` also exposes `CoordinateSystem`, `XLength`, `YLength`,
`ZLength`, and `SelectionMask`. `DwgViewDef` exposes orientation, clipping,
and layout fields. They are not used as a heuristic in this slice because the
query result provides a stronger project-object identity.
## Selected implementation
`PlantViewportMembershipReader.Read` locates exactly one view by the stable
ID already generated from the sheet GUID and view name. It executes that
view's stored `DwgQuery`, converts every returned `MdObjectId.PrjObjectId`
through `DataLinksManager.FindAcPpRowId`, and intersects the result with the
source drawing's Piping rows.
Matching rows receive `Resolved` with
`VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID`. Source rows absent from the confirmed
query result receive `ObjectOutsideView` with
`VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP`. An unsupported or ambiguous runtime
contract stays `NotResolved` with `VIEW_MEMBERSHIP_API_UNSUPPORTED`; the code
does not guess alternate Autodesk properties or select an arbitrary view.
## Legacy analysis
The read-only legacy source at
`\\phpsrv03\NBIM\code\CS\P_Rec_Annot\v1.0.4\repo` was available. Its
`UpdateOrthoDrawings/Commands.cs` configured `DwgQuery.ModelFiles` and called
`DwgQuery.Execute`; `OrthoAutoAnnotate/Commands.cs` and
`AutoAnnotateView/ViewPorts.cs` also used viewport and entity extents. Those
extents checks are a geometric filter, not an authoritative object identity,
so they were not ported as `Resolved` membership.
## Side effects and failure handling
The reader opens no Autodesk write transaction and calls no persistence API.
It does not modify DWG, DCF, or project metadata. `DLException` while mapping
an individual generated identity is ignored only for that non-Piping identity;
other failures yield the stable API diagnostic and preserve no false positive.
Runtime DryRun and paired control/DryRun hash comparison remain required to
verify actual no-write behavior.
@@ -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,455 @@
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 enum PlantProjectDetectionStatus { NotFound, FoundWithName, FoundWithoutDisplayName, Ambiguous, 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 Bounds3Dto(Point3Dto Minimum, Point3Dto Maximum);
public enum ExtentsIntersectionType { DoNotIntersect, Intersect, ExtentsMatch, FirstExtentsInsideSecondExtents, SecondExtentsInsideFirstExtents }
public sealed record LineNumberAnnotationSettings
{
public required string NullOrEmpty { get; init; }
public required IReadOnlyList<LineNumberAnnotationDefinition> Annotations { get; init; }
}
public sealed record LineNumberAnnotationDefinition
{
public required string Nickname { get; init; }
public IReadOnlyDictionary<string, string> Variables { get; init; } = new Dictionary<string, string>();
public required IReadOnlyList<LineNumberAnnotationTag> Tags { get; init; }
}
public sealed record LineNumberAnnotationTag
{
public required string Field { get; init; }
public string Divider { get; init; } = string.Empty;
public bool SaveWhenEmpty { get; init; } = true;
public string IgnoreValue { get; init; } = string.Empty;
public string ProcessingXml { get; init; } = string.Empty;
}
public sealed record LineNumberFormattingResult(bool Success, string Text, string? ErrorCode = null);
public interface ILineNumberAnnotationFormatter
{
LineNumberFormattingResult Format(LineNumberAnnotationDefinition definition, IReadOnlyDictionary<string, string?> fields);
}
public sealed record DataLinksPropertyReadResult(bool Success, IReadOnlyDictionary<string, string?> Properties, string? FailureCode = null);
public interface IDataLinksPropertyReader { DataLinksPropertyReadResult ReadProperties(long rowId); }
public sealed record LineNumberFieldResolution(bool Success, IReadOnlyDictionary<string, string?> Values, string? FailureCode = null);
public sealed record LineNumberCandidate(string StableId, long RowId, AnnotationPoint Position, double Rotation, string Layer, string TextStyle, IReadOnlyDictionary<string, string?> Properties);
public sealed record ExistingLineNumberAnnotation(string StableId, string SourceStableId, string Text);
public sealed record LineNumberCommandOperation(string StableId, AnnotationOperationKind Kind, string? Text, AnnotationPoint Position, double Rotation, string Layer, string TextStyle, IReadOnlyList<string> Reasons);
public sealed record LineNumberCommandPlan(IReadOnlyList<LineNumberCommandOperation> Operations);
public sealed record LineNumberApplyPreflight(string StableId, int MarkerNumber, string Text, AnnotationPoint Position, double Rotation, string Layer, string TextStyle);
public sealed record LineNumberApplyResult(int Created, int Updated, int Unchanged);
public sealed record LineNumberListApplyPreflight(string StableId, AnnotationPoint Position, double Rotation, IReadOnlyList<LineNumberApplyPreflight> Rows);
public sealed record LineNumberPreviewResult(bool Success, LineNumberCommandPlan? Plan, IReadOnlyList<LineNumberApplyPreflight> Apply, string? FailureCode = null);
public sealed record LineNumberCandidateReadResult(bool Success, LineNumberCandidate? Candidate, string? FailureCode = null);
public sealed record ElevationCandidate(string StableId, double ModelElevation, double DatumOffset, AnnotationPoint Position, double Rotation, string Layer, string TextStyle);
public sealed record ExistingElevationAnnotation(string StableId, string SourceStableId, string Text, AnnotationPoint Position);
public sealed record ElevationAnnotationOperation(string StableId, AnnotationOperationKind Kind, string? Text, AnnotationPoint Position, double Rotation, string Layer, string TextStyle, IReadOnlyList<string> Reasons);
public sealed record ElevationAnnotationPlan(IReadOnlyList<ElevationAnnotationOperation> Operations);
public sealed record ElevationAssets(string Layer, string TextStyle, string BlockName = "BLOCK_ELEVATION");
public sealed record ElevationApplyResult(int Created, int Updated, int Unchanged);
public sealed record LineConnectionCandidate(string StableId, string LineNumber, Point3Dto FirstEndpoint, Point3Dto SecondEndpoint, Bounds3Dto Bounds, AnnotationPoint Position, double Rotation);
public sealed record LineConnectionGroup(string LineNumber, IReadOnlyList<LineConnectionCandidate> Members, AnnotationPoint Placement, double Rotation);
// A legacy PXP-ALC annotation belongs to a connector between two *different*
// line groups; it is not a duplicate marker for one line group.
public sealed record LineConnectionBoundaryCandidate(
string StableId,
string FirstLineNumber,
string SecondLineNumber,
Point3Dto FirstEndpoint,
Point3Dto SecondEndpoint,
Bounds3Dto Bounds,
AnnotationPoint Position,
double Rotation,
string? FirstLineGroupStableId = null,
string? SecondLineGroupStableId = null);
public sealed record LineConnectionBoundaryPlan(string StableId, string Text, AnnotationPoint Position, double Rotation, IReadOnlyList<string> Reasons, string? FirstText = null, string? SecondText = null, Point3Dto? ConnectorPosition = null);
public sealed record LineConnectionAssets(string PolylineLayer, string TextLayer, string TextStyle, string BlockName = "BLOCK_LINENUMBER_CONNECTION");
public sealed record LineConnectionApplyResult(int Deleted, int Created);
// The geometry adapter supplies the paper-space connector point and orientation.
// Keeping it separate from the PnP relation snapshot prevents Autodesk objects
// from crossing into the deterministic PXP-ALC planning layer.
public sealed record LineConnectionPlacement(
Point3Dto FirstEndpoint,
Point3Dto SecondEndpoint,
Bounds3Dto Bounds,
AnnotationPoint Position,
double Rotation);
public sealed record LineConnectionPreviewResult(
bool Success,
IReadOnlyList<LineConnectionBoundaryPlan> Plans,
string? FailureCode = null);
public sealed record PlantConnectorRelationshipSnapshot(
string StableId,
long ConnectorRowId,
long FirstPartRowId,
long SecondPartRowId,
long FirstLineGroupRowId,
long SecondLineGroupRowId,
IReadOnlyList<string> Diagnostics,
string? ConnectorEntityStableId = null);
public sealed record PlantConnectorRelationshipReadResult(bool Success, IReadOnlyList<PlantConnectorRelationshipSnapshot> Connectors, string? FailureCode = null);
public interface IPlantConnectorRelationshipReader
{
PlantConnectorRelationshipReadResult Read();
}
public sealed record PlantConnectorPortSnapshot(
string ConnectorStableId,
Point3Dto FirstPosition,
Point3Dto SecondPosition,
Point3Dto FirstDirection,
Point3Dto SecondDirection);
public sealed record PlantConnectorPortReadResult(bool Success, PlantConnectorPortSnapshot? Ports, string? FailureCode = null);
public interface IPlantConnectorPortReader
{
PlantConnectorPortReadResult Read(PlantConnectorRelationshipSnapshot connector);
}
public sealed record ConnectorViewportEligibility(bool Eligible, Point3Dto? PlacementPoint, string? FailureCode = null);
public sealed record LineNumberOwnedRow(string SourceStableId, int MarkerNumber, string Text, AnnotationPoint MarkerAttributePosition);
public sealed record LineNumberRowMutation(string SourceStableId, int PreviousMarkerNumber, int MarkerNumber, string Text, AnnotationPoint? MarkerAttributePosition);
public sealed record LineNumberTextAsset(string Layer, string TextStyle, double Size, string TextValue, Point2Dto Position);
public sealed record LineNumberLineAsset(string Layer, Point2Dto Start, Point2Dto End);
public sealed record LineNumberMarkerAsset(string Layer, double MaskScaleFactor, IReadOnlyList<LineNumberTextAsset> Texts, IReadOnlyList<LineNumberLineAsset> Lines);
public sealed record LineNumberListAsset(string Layer, double MaskScaleFactor, double LineHeight, double LineSpacingDistance, LineNumberTextAsset MarkerText, LineNumberTextAsset LineNumberText, LineNumberLineAsset Line);
public sealed record AnnotateLineNumbersAssets(LineNumberMarkerAsset Marker, LineNumberListAsset List);
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 Vector2Dto? PaperDirection { get; init; }
}
// Platform-neutral result of the legacy GetComponentGeometry contract. The
// Plant adapter is responsible for reading Parts/Pipes and normalising units;
// consumers do not take a dependency on Autodesk managed assemblies.
public sealed record ComponentGeometryDefinition(
string Kind,
IReadOnlyDictionary<string, string> Parameters,
Bounds3Dto Bounds,
bool ImperialDimensions);
public sealed record ComponentGeometryReadResult(bool Success, ComponentGeometryDefinition? Geometry, string? FailureCode = null);
public sealed record ViewportLayerPlan(bool RequiresChange, string TargetLayer, string? FailureCode = null);
public enum OrthoAnnotationRepresentation { None, MultiLeader, BubbleBlock }
public sealed record OrthoAnnotationOptions(bool Supports, bool Valves, bool Instruments, bool Plugs, bool Boards);
public sealed record OrthoAnnotationClassification(string StableId, OrthoAnnotationRepresentation Representation, string Text, IReadOnlyList<string> Reasons);
public sealed record OrthoAnnotationAssets(string Layer, string TextStyle, string MultiLeaderStyle, string BubbleBlock = "BLOCK_ANNO_POSITION");
public sealed record OrthoBatchDrawing(string DrawingGuid, string DisplayName, string RelativePath, IReadOnlyList<string> Views);
public sealed record OrthoBatchSelection(string RelativePath, IReadOnlyList<string> Views);
public sealed record OrthoBatchOperation(string DrawingGuid, string DisplayName, string RelativePath, string ViewName);
public sealed record OrthoBatchPlan(IReadOnlyList<OrthoBatchOperation> Operations, IReadOnlyList<string> Diagnostics);
public enum OrthoLayerFilterKind { Status, Layer, Table }
public sealed record OrthoLayerRule(OrthoLayerFilterKind Kind, string Filter, int? LineWeight, string LineType, short? ColorIndex, byte? Red, byte? Green, byte? Blue)
{
public bool MatchesStatus(string status) => Kind == OrthoLayerFilterKind.Status && string.Equals(Filter, status, StringComparison.OrdinalIgnoreCase);
public bool MatchesLayer(string viewName, string layer) => Kind == OrthoLayerFilterKind.Layer && string.Equals(viewName + "-" + Filter, layer, StringComparison.OrdinalIgnoreCase);
}
public interface IPlantComponentGeometryReader
{
ComponentGeometryReadResult Read(string stableEntityId, bool convertToMetric);
}
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 ViewMembershipStatus MembershipStatus { get; init; } = ViewMembershipStatus.NotResolved;
public IReadOnlyList<string> MembershipEvidenceCodes { get; init; } = Array.Empty<string>();
}
public enum ViewMembershipStatus { NotResolved, Resolved, PartiallyResolved, ObjectOutsideView, Unsupported, Failed }
public sealed record SourceObjectViewportMembership
{
public required string SourceObjectStableKey { get; init; }
public required string ViewStableKey { get; init; }
public required ViewMembershipStatus Status { get; init; }
public IReadOnlyList<string> EvidenceCodes { get; init; } = Array.Empty<string>();
public string? ReasonCode { get; init; }
}
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 PlantProjectDetectionStatus ProjectDetectionStatus { get; init; } = PlantProjectDetectionStatus.NotFound;
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 SourceDrawingResolutionStatus { Resolved, NotReferenced, Ambiguous, MissingFromProject, Unsupported, Failed }
public sealed record OrthoSourceDrawingReference
{
public required string StableId { get; init; }
public required string DisplayName { get; init; }
public string? ProjectRelativePath { get; init; }
public long? ProjectDrawingRowId { get; init; }
public required SourceDrawingResolutionStatus Status { get; init; }
public IReadOnlyList<string> EvidenceCodes { get; init; } = Array.Empty<string>();
}
public sealed record SourcePlantObjectSnapshot
{
public required string StableObjectKey { get; init; }
public required string ClassName { get; init; }
public string? SourceDrawingStableId { get; init; }
public string? LineGroupStableKey { get; init; }
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>();
}
public sealed record SourceDrawingReadResult
{
public required OrthoSourceDrawingReference Source { get; init; }
public IReadOnlyList<SourcePlantObjectSnapshot> Objects { get; init; } = Array.Empty<SourcePlantObjectSnapshot>();
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
}
public interface IOrthoSourceDrawingResolver
{
IReadOnlyList<OrthoSourceDrawingReference> Resolve(OrthoDrawingSnapshot drawing);
}
public sealed record OrthoDrawingSnapshot
{
public required string StableId { get; init; }
public IReadOnlyList<OrthoViewSourceSnapshot> Views { get; init; } = Array.Empty<OrthoViewSourceSnapshot>();
}
public sealed record OrthoViewSourceSnapshot
{
public required string ViewStableId { get; init; }
public IReadOnlyList<string> ReferencedDrawingPaths { get; init; } = Array.Empty<string>();
}
public interface ISourceDrawingDataLinksReader
{
SourceDrawingReadResult Read(PlantProjectContext project, OrthoSourceDrawingReference source);
}
public sealed record PlantProjectContext
{
public required string StableProjectId { get; init; }
public string? ProjectDisplayName { get; init; }
}
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>();
// Test-only limited Apply intent. It is part of the canonical plan and is
// deliberately explicit: the planner must never choose an arbitrary Pipe.
public string? LimitedApplyTargetSnapshotId { get; init; }
public string? LimitedApplyRuleId { get; init; }
}
public interface IAnnotationPlanBuilder { AnnotationPlan Build(AnnotationPlanningRequest request); }
public enum PRecAnnotExecutionMode { Smoke, DryRun, Apply }
public interface IRuntimeWriteGuard { void EnsureWriteAllowed(string operation); }
public sealed record CreatePipeAnnotationOperation
{
public required string OperationId { get; init; }
public required string SourceObjectStableKey { get; init; }
public required string ViewStableKey { get; init; }
public required string RuleId { get; init; }
public required AnnotationPoint Placement { get; init; }
public required string DuplicateKey { get; init; }
}
public sealed record FirstLimitedApplyPermit
{
public required string FixtureMarker { get; init; }
public required bool AllowApply { get; init; }
public required string TargetSnapshotId { get; init; }
public required string ExpectedPlanId { get; init; }
}
public sealed record FirstLimitedApplyPreflight
{
public required string PlanId { get; init; }
public required CreatePipeAnnotationOperation Operation { get; init; }
}
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 int PlanSchemaVersion { get; init; }
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,35 @@
using System.Globalization;
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class AnnotateLineNumbersSettingsLoader
{
public AnnotateLineNumbersAssets Load(string xml)
{
var lineNumbers = XElement.Parse(xml).Element("LineNumbers") ?? throw Invalid("LineNumbers");
var marker = lineNumbers.Element("Marker") ?? throw Invalid("Marker");
var list = lineNumbers.Element("List") ?? throw Invalid("List");
var texts = marker.Elements("Text").Select(x => Text(x)).ToArray();
var lines = marker.Elements("Line").Select(Line).ToArray();
if (texts.Length == 0 || lines.Length == 0) throw Invalid("MarkerContent");
return new(
new(Required(marker, "layer"), Number(marker, "maskscalefactor"), texts, lines),
new(Required(list, "layer"), Number(list, "maskscalefactor"), Number(list, "lineheight"), Number(list, "linespacingdistance"), Text(RequiredElement(list, "MarkerText"), false), Text(RequiredElement(list, "LineNumberText"), false), Line(RequiredElement(list, "Line"))));
}
private static LineNumberTextAsset Text(XElement value, bool readValue = true) => new(Required(value, "layer"), Required(value, "textstyle"), Number(value, "size"), readValue ? Required(value, "textvalue", true) : string.Empty, new(Number(value, "positionx"), Number(value, "positiony")));
private static LineNumberLineAsset Line(XElement value) => new(Required(value, "layer"), new(Number(value, "startx"), Number(value, "starty")), new(OptionalNumber(value, "endx"), OptionalNumber(value, "endy")));
private static XElement RequiredElement(XElement parent, string name) => parent.Element(name) ?? throw Invalid(name);
private static string Required(XElement value, string name, bool emptyAllowed = false)
{
var attribute = value.Attribute(name) ?? throw Invalid(name);
if (!emptyAllowed && string.IsNullOrWhiteSpace(attribute.Value)) throw Invalid(name);
return attribute.Value;
}
private static double Number(XElement value, string name) => Parse(Required(value, name));
private static double OptionalNumber(XElement value, string name) => value.Attribute(name) is { } attribute ? Parse(attribute.Value) : 0;
private static double Parse(string value) => double.TryParse(value.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out var result) ? result : throw Invalid("number");
private static InvalidDataException Invalid(string field) => new("LINE_NUMBER_SETTINGS_INVALID:" + field);
}
@@ -0,0 +1,193 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
{
public const int CurrentPlanSchemaVersion = 2;
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;
}
if (!string.IsNullOrWhiteSpace(request.LimitedApplyTargetSnapshotId) &&
!string.Equals(item.SnapshotId, request.LimitedApplyTargetSnapshotId, StringComparison.Ordinal))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "APPLY_NOT_SELECTED_TARGET" } });
continue;
}
if (item.MembershipStatus is not ViewMembershipStatus.Resolved and not ViewMembershipStatus.PartiallyResolved)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { MembershipReason(item.MembershipStatus) } });
continue;
}
// The first Apply slice is intentionally narrower than the
// historic bulk rules: exactly one explicitly selected, resolved
// Pipe can become a PIPE block operation. All other objects keep
// their normal diagnostic treatment.
if (!string.IsNullOrWhiteSpace(request.LimitedApplyTargetSnapshotId) &&
string.Equals(item.SnapshotId, request.LimitedApplyTargetSnapshotId, StringComparison.Ordinal))
{
if (!string.Equals(item.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Unsupported, Reasons = new[] { "APPLY_UNSUPPORTED_OBJECT_CLASS" } });
continue;
}
if (item.MembershipStatus != ViewMembershipStatus.Resolved)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "APPLY_REQUIRES_RESOLVED_MEMBERSHIP" } });
continue;
}
if (item.Geometry.PaperPosition is not { } pipePosition)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "ANNOTATION_PLACEMENT_NOT_RESOLVED" } });
continue;
}
var pipeIntent = new AnnotationIntent
{
StableKey = $"{request.Viewport.Id}:{item.SnapshotId}:{FirstLimitedApplyPlanner.RuleId}",
Text = FirstLimitedApplyPlanner.RuleId,
Position = new AnnotationPoint(pipePosition.X, pipePosition.Y),
Rotation = item.Geometry.Rotation ?? 0,
ClassName = item.ObjectClass,
Layer = "PXP_ANNOTATION"
};
operations.Add(new AnnotationOperationPlan
{
SourceSnapshotId = item.SnapshotId,
Kind = AnnotationOperationKind.Create,
AnnotationType = "PIPE",
Text = FirstLimitedApplyPlanner.RuleId,
Position = pipePosition,
Rotation = item.Geometry.Rotation,
Reasons = Array.Empty<string>()
});
creates.Add(pipeIntent);
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
{
PlanSchemaVersion = CurrentPlanSchemaVersion,
PlanId = BuildPlanId(request, objects, operations),
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).OrderBy(x => x, StringComparer.Ordinal).ToArray(),
ExaminedCount = objects.Length,
IsDeterministic = true
};
}
private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList<PlantObjectSnapshot> objects, IReadOnlyList<AnnotationOperationPlan> operations)
{
var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion)
.Append("|command=").Append(Normalize(request.CommandName))
.Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName)))
.Append("|view=").Append(Normalize(request.Viewport.Id))
.Append("|limited-target=").Append(Normalize(request.LimitedApplyTargetSnapshotId))
.Append("|limited-rule=").Append(Normalize(request.LimitedApplyRuleId));
foreach (var item in objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal))
{
canonical.Append("|object=").Append(Normalize(item.SnapshotId)).Append('|').Append(Normalize(item.ObjectClass))
.Append('|').Append(Normalize(item.Tag)).Append('|').Append(Normalize(item.LineNumber))
.Append('|').Append(item.MembershipStatus);
foreach (var property in item.Properties.OrderBy(x => x.Key, StringComparer.Ordinal))
canonical.Append("|property=").Append(Normalize(property.Key)).Append('=').Append(Normalize(property.Value));
}
foreach (var operation in operations.OrderBy(x => x.SourceSnapshotId, StringComparer.Ordinal).ThenBy(x => x.Kind))
canonical.Append("|operation=").Append(Normalize(operation.SourceSnapshotId)).Append(':').Append(operation.Kind).Append(':').Append(string.Join(',', operation.Reasons.OrderBy(x => x, StringComparer.Ordinal)));
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()));
return "plan-" + Convert.ToHexString(hash)[..24];
}
private static string Normalize(string? value) => (value ?? string.Empty).Trim().Normalize(NormalizationForm.FormC);
private static string MembershipReason(ViewMembershipStatus status) => status switch
{
ViewMembershipStatus.ObjectOutsideView => "VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP",
ViewMembershipStatus.Unsupported => "VIEW_MEMBERSHIP_API_UNSUPPORTED",
ViewMembershipStatus.Failed => "VIEW_MEMBERSHIP_TRANSFORM_FAILED",
_ => "VIEW_MEMBERSHIP_NOT_RESOLVED"
};
}
@@ -0,0 +1,27 @@
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 },
MembershipStatus = ViewMembershipStatus.Resolved
}).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,15 @@
namespace PRecAnnot.Core;
public static class AttributeSwapPlanner
{
public static double ComputeShelfLength(double firstWidth, double firstOffset, double secondWidth, double secondOffset) =>
Math.Abs(Math.Max(firstWidth + firstOffset, secondWidth + secondOffset) + .9);
public static IReadOnlyDictionary<string, string> Swap(IReadOnlyDictionary<string, string> metadata, string first, string second)
{
var result = new Dictionary<string, string>(metadata, StringComparer.Ordinal);
if (result.TryGetValue(first, out var firstValue) && result.TryGetValue(second, out var secondValue))
{ result[first] = secondValue; result[second] = firstValue; }
return result;
}
}
@@ -0,0 +1,34 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
// Port of AutoLinenumberConnections.IsOKPrepareAnnotation's geometry gate.
// The caller performs model-to-paper transformation only after the connector
// is eligible, preserving legacy model-space containment semantics.
public sealed class ConnectorViewportEligibilityEvaluator
{
public ConnectorViewportEligibility Evaluate(PlantConnectorPortSnapshot ports, Point3Dto viewDirection, Bounds3Dto viewExtents, double parallelTolerance = 0.1)
{
if (parallelTolerance < 0) throw new ArgumentOutOfRangeException(nameof(parallelTolerance));
if (Parallel(ports.FirstDirection, viewDirection, parallelTolerance) || Parallel(ports.SecondDirection, viewDirection, parallelTolerance))
return new(false, null, "CONNECTOR_PORT_PARALLEL_TO_VIEW");
if (!Inside(ports.FirstPosition, viewExtents) || !Inside(ports.SecondPosition, viewExtents))
return new(false, null, "CONNECTOR_OUTSIDE_VIEW_EXTENTS");
// Legacy stores ports[1] as the insertion/model anchor.
return new(true, ports.SecondPosition);
}
private static bool Inside(Point3Dto point, Bounds3Dto bounds) =>
point.X >= bounds.Minimum.X && point.X <= bounds.Maximum.X &&
point.Y >= bounds.Minimum.Y && point.Y <= bounds.Maximum.Y &&
point.Z >= bounds.Minimum.Z && point.Z <= bounds.Maximum.Z;
private static bool Parallel(Point3Dto first, Point3Dto second, double tolerance)
{
var firstLength = Math.Sqrt(first.X * first.X + first.Y * first.Y + first.Z * first.Z);
var secondLength = Math.Sqrt(second.X * second.X + second.Y * second.Y + second.Z * second.Z);
if (firstLength == 0 || secondLength == 0) return true;
var cosine = Math.Abs((first.X * second.X + first.Y * second.Y + first.Z * second.Z) / (firstLength * secondLength));
return 1d - cosine <= tolerance;
}
}
@@ -0,0 +1,39 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class ElevationAnnotationPlanner
{
public ElevationAnnotationPlan Build(IEnumerable<ElevationCandidate> candidates, IEnumerable<ExistingElevationAnnotation> existing)
{
var known = existing.GroupBy(value => value.SourceStableId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var operations = new List<ElevationAnnotationOperation>();
foreach (var candidate in candidates.OrderBy(value => value.StableId, StringComparer.Ordinal))
{
if (!seen.Add(candidate.StableId))
{
operations.Add(new(candidate.StableId, AnnotationOperationKind.Conflict, null, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["DUPLICATE_CANDIDATE"]));
continue;
}
if (string.IsNullOrWhiteSpace(candidate.Layer) || string.IsNullOrWhiteSpace(candidate.TextStyle) || double.IsNaN(candidate.ModelElevation) || double.IsInfinity(candidate.ModelElevation))
{
operations.Add(new(candidate.StableId, AnnotationOperationKind.Skip, null, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["ELEVATION_CANDIDATE_INVALID"]));
continue;
}
var text = LegacyElevationFormatter.Calculate(candidate.ModelElevation, candidate.DatumOffset);
var matches = known.GetValueOrDefault(candidate.StableId, []);
if (matches.Length > 1)
{
operations.Add(new(candidate.StableId, AnnotationOperationKind.Conflict, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["DUPLICATE_EXISTING_ANNOTATION"]));
continue;
}
if (matches.Length == 1 && string.Equals(matches[0].Text, text, StringComparison.Ordinal) && matches[0].Position == candidate.Position)
operations.Add(new(candidate.StableId, AnnotationOperationKind.Skip, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, ["ALREADY_CURRENT"]));
else
operations.Add(new(candidate.StableId, matches.Length == 1 ? AnnotationOperationKind.Update : AnnotationOperationKind.Create, text, candidate.Position, candidate.Rotation, candidate.Layer, candidate.TextStyle, Array.Empty<string>()));
}
return new(operations);
}
}
@@ -0,0 +1,19 @@
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class ElevationSettingsLoader
{
public ElevationAssets Load(string xml)
{
var element = XElement.Parse(xml).Element("Elevations")
?? throw new InvalidDataException("ELEVATION_SETTINGS_INVALID:Elevations");
return new ElevationAssets(Required(element, "layer"), Required(element, "textstyle"));
}
private static string Required(XElement element, string name) =>
element.Attribute(name) is { Value: var value } && !string.IsNullOrWhiteSpace(value)
? value
: throw new InvalidDataException("ELEVATION_SETTINGS_INVALID:" + name);
}
@@ -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,18 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public static class ExtentsIntersectionClassifier
{
// Source-backed port of ExtentsIntersect.Commands without Autodesk types.
public static ExtentsIntersectionType Classify(Bounds3Dto first, Bounds3Dto second)
{
if (Same(first, second)) return ExtentsIntersectionType.ExtentsMatch;
if (Contains(first, second)) return ExtentsIntersectionType.SecondExtentsInsideFirstExtents;
if (Contains(second, first)) return ExtentsIntersectionType.FirstExtentsInsideSecondExtents;
return Overlaps(first, second) ? ExtentsIntersectionType.Intersect : ExtentsIntersectionType.DoNotIntersect;
}
private static bool Contains(Bounds3Dto outer, Bounds3Dto inner) => outer.Minimum.X <= inner.Minimum.X && outer.Minimum.Y <= inner.Minimum.Y && outer.Minimum.Z <= inner.Minimum.Z && outer.Maximum.X >= inner.Maximum.X && outer.Maximum.Y >= inner.Maximum.Y && outer.Maximum.Z >= inner.Maximum.Z;
private static bool Overlaps(Bounds3Dto a, Bounds3Dto b) => a.Minimum.X <= b.Maximum.X && a.Maximum.X >= b.Minimum.X && a.Minimum.Y <= b.Maximum.Y && a.Maximum.Y >= b.Minimum.Y && a.Minimum.Z <= b.Maximum.Z && a.Maximum.Z >= b.Minimum.Z;
private static bool Same(Bounds3Dto a, Bounds3Dto b) => a == b;
}
@@ -0,0 +1,52 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class FirstLimitedApplyPlanner
{
public const string FixtureMarker = "FIRST_LIMITED_APPLY";
public const string RuleId = "PXP-OA.PIPE.OPEN-END.V1";
public FirstLimitedApplyPreflight Prepare(
PRecAnnotExecutionMode mode,
AnnotationPlan plan,
OrthoViewportInfo viewport,
IReadOnlyList<PlantObjectSnapshot> objects,
FirstLimitedApplyPermit permit)
{
if (mode != PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_REQUIRED");
if (!permit.AllowApply || !string.Equals(permit.FixtureMarker, FixtureMarker, StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
if (!string.Equals(permit.ExpectedPlanId, plan.PlanId, StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_PLAN_STALE");
var targets = objects.Where(x => string.Equals(x.SnapshotId, permit.TargetSnapshotId, StringComparison.Ordinal)).ToArray();
if (targets.Length != 1) throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_TARGET");
var target = targets[0];
if (target.MembershipStatus != ViewMembershipStatus.Resolved) throw new InvalidOperationException("APPLY_REQUIRES_RESOLVED_MEMBERSHIP");
if (!string.Equals(target.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("APPLY_UNSUPPORTED_OBJECT_CLASS");
if (target.Geometry.PaperPosition is not { } placement) throw new InvalidOperationException("ANNOTATION_PLACEMENT_NOT_RESOLVED");
var create = plan.Operations.Where(x => x.Kind == AnnotationOperationKind.Create && string.Equals(x.SourceSnapshotId, target.SnapshotId, StringComparison.Ordinal)).ToArray();
if (create.Length != 1) throw new InvalidOperationException("APPLY_TARGET_CREATE_NOT_PLANNED");
var duplicateKey = Stable("duplicate", RuleId, viewport.Id, target.SnapshotId);
return new FirstLimitedApplyPreflight
{
PlanId = plan.PlanId,
Operation = new CreatePipeAnnotationOperation
{
OperationId = Stable("operation", RuleId, viewport.Id, target.SnapshotId),
SourceObjectStableKey = target.SnapshotId,
ViewStableKey = viewport.Id,
RuleId = RuleId,
Placement = new AnnotationPoint(placement.X, placement.Y),
DuplicateKey = duplicateKey
}
};
}
private static string Stable(params string[] values) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", values))))[..24];
}
@@ -0,0 +1,26 @@
using System.Globalization;
namespace PRecAnnot.Core;
public static class LegacyElevationFormatter
{
public static int RoundModelElevation(double modelZ, double datumOffset)
{
// InsertElevation.BlockJig divides the model-space ordinate by five,
// rounds that quotient away from zero and restores the five-unit grid
// before formatting the signed metres/millimetres value.
var grid = Math.Round((modelZ + datumOffset) / 5d, MidpointRounding.AwayFromZero) * 5d;
return checked((int)Math.Round(grid, MidpointRounding.AwayFromZero));
}
public static string FormatMillimetres(int millimetres)
{
if (millimetres == 0) return "0,000";
var magnitude = Math.Abs((long)millimetres).ToString(CultureInfo.InvariantCulture).PadLeft(4, '0');
var metres = magnitude[..^3];
var fraction = magnitude[^3..];
return $"{(millimetres > 0 ? "+" : "-")}{metres},{fraction}";
}
public static string Calculate(double modelZ, double datumOffset) => FormatMillimetres(RoundModelElevation(modelZ, datumOffset));
}
@@ -0,0 +1,48 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
// Source-backed PXP-ALC decision logic: only a connector whose related parts
// resolve to different line groups produces an annotation. The connector is
// the stable ownership identity, preserving repeat/update behaviour.
public sealed class LineConnectionBoundaryPlanner
{
public IReadOnlyList<LineConnectionBoundaryPlan> Build(IEnumerable<LineConnectionBoundaryCandidate> candidates)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<LineConnectionBoundaryPlan>();
foreach (var candidate in candidates.OrderBy(value => value.StableId, StringComparer.Ordinal))
{
if (!seen.Add(candidate.StableId))
{
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["DUPLICATE_CONNECTOR_CANDIDATE"], ConnectorPosition: candidate.FirstEndpoint));
continue;
}
var first = candidate.FirstLineNumber?.Trim() ?? string.Empty;
var second = candidate.SecondLineNumber?.Trim() ?? string.Empty;
// The legacy command gates insertion on the two P3dLineGroup
// relationship IDs, not just on their rendered text. Equal text in
// distinct groups remains a real connector boundary; equal group IDs
// never does.
if (!string.IsNullOrWhiteSpace(candidate.FirstLineGroupStableId) &&
string.Equals(candidate.FirstLineGroupStableId, candidate.SecondLineGroupStableId, StringComparison.OrdinalIgnoreCase))
{
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["SAME_LINE_GROUP"], ConnectorPosition: candidate.FirstEndpoint));
continue;
}
if (first.Length == 0 || second.Length == 0)
{
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["LINE_GROUP_NOT_RESOLVED"], ConnectorPosition: candidate.FirstEndpoint));
continue;
}
if (string.Equals(first, second, StringComparison.OrdinalIgnoreCase))
{
result.Add(new(candidate.StableId, string.Empty, candidate.Position, candidate.Rotation, ["SAME_LINE_GROUP"], ConnectorPosition: candidate.FirstEndpoint));
continue;
}
var ordered = new[] { first, second }.OrderBy(value => value, StringComparer.OrdinalIgnoreCase).ToArray();
result.Add(new(candidate.StableId, string.Join(" / ", ordered), candidate.Position, candidate.Rotation, Array.Empty<string>(), first, second, candidate.FirstEndpoint));
}
return result;
}
}
@@ -0,0 +1,72 @@
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
// Source-backed bridge for AutoLinenumberConnections: each endpoint uses the
// same GetLinenumberAnnotationText formatting contract as PXP-LN, while the
// connector itself remains the stable annotation ownership key.
public sealed class LineConnectionPreviewService(
LineNumberFieldResolver fieldResolver,
ILineNumberAnnotationFormatter formatter,
LineConnectionBoundaryPlanner boundaryPlanner)
{
public LineConnectionPreviewResult Preview(
LineNumberAnnotationDefinition definition,
IEnumerable<PlantConnectorRelationshipSnapshot> connectors,
IReadOnlyDictionary<string, LineConnectionPlacement> placements)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(connectors);
ArgumentNullException.ThrowIfNull(placements);
var candidates = new List<LineConnectionBoundaryCandidate>();
foreach (var connector in connectors.OrderBy(value => value.StableId, StringComparer.Ordinal))
{
if (!placements.TryGetValue(connector.StableId, out var placement))
{
candidates.Add(new(connector.StableId, string.Empty, string.Empty, new(), new(), new(new(), new()), new(), 0,
connector.FirstLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture),
connector.SecondLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture)));
continue;
}
var fields = definition.Tags.Select(tag => tag.Field).Where(field => !string.IsNullOrWhiteSpace(field));
var first = ResolveText(definition, connector.FirstPartRowId, fields);
var second = ResolveText(definition, connector.SecondPartRowId, fields);
candidates.Add(new(
connector.StableId,
first.Text,
second.Text,
placement.FirstEndpoint,
placement.SecondEndpoint,
placement.Bounds,
placement.Position,
placement.Rotation,
connector.FirstLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture),
connector.SecondLineGroupRowId.ToString(System.Globalization.CultureInfo.InvariantCulture)));
}
var plans = boundaryPlanner.Build(candidates);
// Per-connector property failures are represented as non-insertable
// plans, matching legacy's continue-on-one-bad-connector behaviour.
var repaired = plans.Select(plan =>
{
var connector = connectors.First(value => string.Equals(value.StableId, plan.StableId, StringComparison.Ordinal));
if (!placements.ContainsKey(plan.StableId)) return plan with { Reasons = plan.Reasons.Append("CONNECTOR_GEOMETRY_UNAVAILABLE").Distinct(StringComparer.Ordinal).ToArray() };
var fields = definition.Tags.Select(tag => tag.Field).Where(field => !string.IsNullOrWhiteSpace(field));
var first = ResolveText(definition, connector.FirstPartRowId, fields);
var second = ResolveText(definition, connector.SecondPartRowId, fields);
var reasons = plan.Reasons.Concat(first.Error is null ? [] : [first.Error]).Concat(second.Error is null ? [] : [second.Error]).Distinct(StringComparer.Ordinal).ToArray();
return reasons.Length == plan.Reasons.Count ? plan : plan with { Text = reasons.Length == 0 ? plan.Text : string.Empty, Reasons = reasons };
}).ToArray();
return new(true, repaired);
}
private (string Text, string? Error) ResolveText(LineNumberAnnotationDefinition definition, long rowId, IEnumerable<string> fields)
{
var resolved = fieldResolver.Resolve(rowId, fields);
if (!resolved.Success) return (string.Empty, resolved.FailureCode ?? "DATALINKS_PROPERTY_READ_FAILED");
var formatted = formatter.Format(definition, resolved.Values);
return formatted.Success ? (formatted.Text, null) : (string.Empty, formatted.ErrorCode ?? "LINE_NUMBER_FORMAT_FAILED");
}
}
@@ -0,0 +1,20 @@
using System.Xml.Linq;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class LineConnectionSettingsLoader
{
public LineConnectionAssets Load(string xml)
{
try
{
var element = XDocument.Parse(xml).Root?.Element("LinenumberConnections") ?? throw new InvalidOperationException("LINE_CONNECTION_SETTINGS_MISSING");
string Required(string name) => string.IsNullOrWhiteSpace((string?)element.Attribute(name))
? throw new InvalidOperationException("LINE_CONNECTION_SETTING_MISSING:" + name)
: (string)element.Attribute(name)!;
return new(Required("polylinelayer"), Required("textlayer"), Required("textstyle"));
}
catch (System.Xml.XmlException exception) { throw new InvalidOperationException("LINE_CONNECTION_SETTINGS_XML_INVALID", exception); }
}
}

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