@beyondwork/docx-react-component 1.0.67 → 1.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (384) hide show
  1. package/README.md +75 -932
  2. package/package.json +26 -27
  3. package/src/api/anchor-conversion.ts +43 -0
  4. package/src/api/editor-state-types.ts +2 -1
  5. package/src/api/public-types.ts +504 -101
  6. package/src/api/session-state.ts +4 -0
  7. package/src/api/v3/README.md +91 -0
  8. package/src/api/v3/_create.ts +146 -0
  9. package/src/api/v3/_layer-metadata.ts +362 -0
  10. package/src/api/v3/_mocks.ts +84 -0
  11. package/src/api/v3/_runtime-handle.ts +162 -0
  12. package/src/api/v3/_ux-response.ts +73 -0
  13. package/src/api/v3/ai/_metadata-audit.ts +225 -0
  14. package/src/api/v3/ai/attach.ts +235 -0
  15. package/src/api/v3/ai/bundle.ts +132 -0
  16. package/src/api/v3/ai/explain.ts +144 -0
  17. package/src/api/v3/ai/export.ts +54 -0
  18. package/src/api/v3/ai/inspect.ts +118 -0
  19. package/src/api/v3/ai/policy.ts +77 -0
  20. package/src/api/v3/ai/replacement.ts +341 -0
  21. package/src/api/v3/ai/resolve.ts +133 -0
  22. package/src/api/v3/index.ts +79 -0
  23. package/src/api/v3/runtime/chart.ts +310 -0
  24. package/src/api/v3/runtime/clipboard.ts +81 -0
  25. package/src/api/v3/runtime/collab.ts +331 -0
  26. package/src/api/v3/runtime/content.ts +236 -0
  27. package/src/api/v3/runtime/document.ts +282 -0
  28. package/src/api/v3/runtime/formatting.ts +186 -0
  29. package/src/api/v3/runtime/geometry.ts +349 -0
  30. package/src/api/v3/runtime/layout.ts +108 -0
  31. package/src/api/v3/runtime/review.ts +129 -0
  32. package/src/api/v3/runtime/search.ts +74 -0
  33. package/src/api/v3/runtime/table.ts +63 -0
  34. package/src/api/v3/runtime/workflow.ts +434 -0
  35. package/src/api/v3/ui/_context.ts +86 -0
  36. package/src/api/v3/ui/_create.ts +65 -0
  37. package/src/api/v3/ui/_types.ts +520 -0
  38. package/src/api/v3/ui/chrome-composition.ts +342 -0
  39. package/src/{ui-tailwind/chrome → api/v3/ui}/chrome-preset-model.ts +11 -1
  40. package/src/api/v3/ui/chrome.ts +476 -0
  41. package/src/api/v3/ui/debug.ts +124 -0
  42. package/src/api/v3/ui/index.ts +64 -0
  43. package/src/api/v3/ui/overlays-visibility.ts +170 -0
  44. package/src/api/v3/ui/overlays.ts +427 -0
  45. package/src/api/v3/ui/scope.ts +71 -0
  46. package/src/api/v3/ui/session.ts +100 -0
  47. package/src/api/v3/ui/surface.ts +170 -0
  48. package/src/api/v3/ui/viewport.ts +303 -0
  49. package/src/core/commands/index.ts +28 -6
  50. package/src/core/commands/list-commands.ts +3 -2
  51. package/src/core/commands/section-layout-commands.ts +9 -8
  52. package/src/core/schema/text-schema.ts +16 -0
  53. package/src/core/selection/mapping.ts +33 -72
  54. package/src/core/state/editor-state.ts +96 -189
  55. package/src/index.ts +23 -4
  56. package/src/io/chart-preview-resolver.ts +1 -1
  57. package/src/io/docx-session.ts +36 -4797
  58. package/src/io/export/build-app-properties-xml.ts +1 -1
  59. package/src/io/export/serialize-comments.ts +1 -1
  60. package/src/io/export/serialize-headers-footers.ts +6 -1
  61. package/src/io/export/serialize-main-document.ts +45 -0
  62. package/src/io/export/serialize-run-formatting.ts +17 -2
  63. package/src/io/export/twip.ts +1 -1
  64. package/src/io/normalize/normalize-text.ts +27 -20
  65. package/src/io/ooxml/chart/parse-series.ts +1 -1
  66. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  67. package/src/io/ooxml/chart/types.ts +1 -1
  68. package/src/io/ooxml/classify-embedding.ts +83 -33
  69. package/src/io/ooxml/parse-fill.ts +1 -1
  70. package/src/io/ooxml/parse-main-document.ts +71 -1
  71. package/src/io/ooxml/parse-object.ts +14 -10
  72. package/src/io/ooxml/parse-run-formatting.ts +47 -1
  73. package/src/io/ooxml/property-grab-bag.ts +2 -2
  74. package/src/io/ooxml/units.ts +11 -0
  75. package/src/io/ooxml/workflow-payload.ts +282 -7
  76. package/src/model/anchor.ts +85 -0
  77. package/src/model/canonical-document.ts +351 -15
  78. package/src/model/chart-types.ts +1 -1
  79. package/src/model/layout/index.ts +83 -0
  80. package/src/model/layout/page-graph-types.ts +181 -0
  81. package/src/model/layout/page-layout-snapshot.ts +105 -0
  82. package/src/model/layout/resolved-layout-types.ts +47 -0
  83. package/src/model/layout/runtime-page-graph-types.ts +102 -0
  84. package/src/model/paragraph-scope-ids.ts +72 -0
  85. package/src/model/review/comment-types.ts +112 -0
  86. package/src/model/review/index.ts +2 -0
  87. package/src/model/review/revision-types.ts +215 -0
  88. package/src/model/snapshot.ts +32 -0
  89. package/src/review/store/comment-store.ts +21 -47
  90. package/src/review/store/revision-types.ts +40 -198
  91. package/src/runtime/collab/base-doc-fingerprint.ts +6 -1
  92. package/src/runtime/collab/runtime-collab-sync.ts +13 -3
  93. package/src/runtime/collab-session.ts +1 -1
  94. package/src/runtime/debug/build-debug-inspector-snapshot.ts +686 -0
  95. package/src/runtime/debug/event-ring-buffer.ts +64 -0
  96. package/src/runtime/debug/probability-sampler.ts +18 -0
  97. package/src/runtime/debug/runtime-debug-facet.ts +67 -0
  98. package/src/runtime/debug/stage-tokens.ts +31 -0
  99. package/src/runtime/debug/telemetry-bus.ts +271 -0
  100. package/src/runtime/debug/types.ts +275 -0
  101. package/src/runtime/debug/wrap-ref-for-telemetry.ts +118 -0
  102. package/src/runtime/document-layout.ts +8 -6
  103. package/src/runtime/document-runtime.ts +843 -1141
  104. package/src/runtime/document-search.ts +1 -1
  105. package/src/runtime/edit-ops/index.ts +1 -1
  106. package/src/runtime/external-send-runtime.ts +1 -1
  107. package/src/runtime/formatting/document-lookup.ts +235 -0
  108. package/src/runtime/formatting/field/registry.ts +41 -0
  109. package/src/runtime/{field-resolver.ts → formatting/field/resolver.ts} +27 -2
  110. package/src/runtime/formatting/font-resolution.ts +83 -0
  111. package/src/runtime/formatting/formatting-context.ts +903 -0
  112. package/src/runtime/formatting/formatting-types.ts +157 -0
  113. package/src/runtime/{hyperlink-color-resolver.ts → formatting/hyperlink-color.ts} +2 -2
  114. package/src/runtime/formatting/index.ts +125 -0
  115. package/src/runtime/{resolved-numbering-geometry.ts → formatting/numbering/geometry.ts} +1 -1
  116. package/src/runtime/{numbering-prefix.ts → formatting/numbering/prefix.ts} +170 -3
  117. package/src/runtime/formatting/paragraph-style-resolver.ts +92 -0
  118. package/src/runtime/formatting/projector.ts +75 -0
  119. package/src/runtime/formatting/resolve-effective.ts +407 -0
  120. package/src/runtime/formatting/revision-display.ts +105 -0
  121. package/src/runtime/{paragraph-style-resolver.ts → formatting/style-cascade.ts} +84 -141
  122. package/src/runtime/{table-style-resolver.ts → formatting/table-style-resolver.ts} +1 -1
  123. package/src/runtime/formatting/telemetry-bridge.ts +106 -0
  124. package/src/runtime/{theme-color-resolver.ts → formatting/theme-color.ts} +2 -30
  125. package/src/runtime/geometry/caret-geometry.ts +164 -0
  126. package/src/runtime/geometry/geometry-facet.ts +364 -0
  127. package/src/runtime/geometry/geometry-types.ts +256 -0
  128. package/src/runtime/geometry/hit-test.ts +125 -0
  129. package/src/runtime/geometry/index.ts +71 -0
  130. package/src/runtime/geometry/inert-geometry-facet.ts +43 -0
  131. package/src/runtime/geometry/invalidation.ts +35 -0
  132. package/src/runtime/geometry/object-handles.ts +77 -0
  133. package/src/runtime/geometry/overlay-rects.ts +85 -0
  134. package/src/runtime/geometry/project-anchors.ts +100 -0
  135. package/src/runtime/geometry/project-fragments.ts +216 -0
  136. package/src/runtime/geometry/projector.ts +129 -0
  137. package/src/runtime/geometry/replacement-envelope.ts +130 -0
  138. package/src/runtime/geometry/viewport.ts +218 -0
  139. package/src/runtime/layout/compat-input-ledger.ts +211 -0
  140. package/src/runtime/layout/index.ts +6 -1
  141. package/src/runtime/layout/inert-layout-facet.ts +12 -7
  142. package/src/runtime/layout/layout-engine-instance.ts +189 -11
  143. package/src/runtime/layout/layout-engine-version.ts +450 -1
  144. package/src/runtime/layout/layout-facet-types.ts +60 -0
  145. package/src/runtime/layout/layout-measurement-provider.ts +13 -0
  146. package/src/runtime/layout/measurement-backend-canvas.ts +14 -2
  147. package/src/runtime/layout/measurement-backend-empirical.ts +23 -4
  148. package/src/runtime/layout/page-graph.ts +62 -209
  149. package/src/runtime/layout/page-story-resolver.ts +7 -12
  150. package/src/runtime/layout/paginated-layout-engine.ts +186 -11
  151. package/src/runtime/layout/project-block-fragments.ts +11 -0
  152. package/src/runtime/layout/projector.ts +90 -0
  153. package/src/runtime/layout/public-facet.ts +187 -442
  154. package/src/runtime/layout/resolved-formatting-state.ts +158 -26
  155. package/src/runtime/layout/table-render-plan.ts +1 -1
  156. package/src/runtime/prerender/cache-envelope.ts +6 -1
  157. package/src/runtime/prerender/prerender-document.ts +18 -23
  158. package/src/runtime/render/decoration-resolver.ts +1 -1
  159. package/src/runtime/render/render-frame-types.ts +20 -0
  160. package/src/runtime/render/render-kernel.ts +94 -25
  161. package/src/runtime/scopes/_formatting-seam.ts +262 -0
  162. package/src/runtime/scopes/_scope-dependencies.ts +49 -0
  163. package/src/runtime/scopes/action-validation.ts +356 -0
  164. package/src/runtime/scopes/attach-explanation.ts +102 -0
  165. package/src/runtime/scopes/audit-bundle.ts +71 -0
  166. package/src/runtime/scopes/compile-scope-bundle.ts +163 -0
  167. package/src/runtime/scopes/compile-scope.ts +262 -0
  168. package/src/runtime/scopes/compiler-service.ts +431 -0
  169. package/src/runtime/scopes/create-issue.ts +107 -0
  170. package/src/runtime/scopes/enumerate-scopes.ts +543 -0
  171. package/src/runtime/scopes/evidence.ts +233 -0
  172. package/src/runtime/scopes/index.ts +150 -0
  173. package/src/runtime/scopes/position-map.ts +214 -0
  174. package/src/runtime/scopes/preservation-boundary.ts +91 -0
  175. package/src/runtime/scopes/projector.ts +49 -0
  176. package/src/runtime/scopes/replaceability.ts +87 -0
  177. package/src/runtime/scopes/replacement/apply.ts +228 -0
  178. package/src/runtime/scopes/replacement/compile.ts +59 -0
  179. package/src/runtime/scopes/replacement/propose.ts +42 -0
  180. package/src/runtime/scopes/resolve-reference.ts +347 -0
  181. package/src/runtime/scopes/review-bundle.ts +141 -0
  182. package/src/runtime/scopes/scope-kinds/_paragraph-text.ts +57 -0
  183. package/src/runtime/scopes/scope-kinds/_table-text.ts +42 -0
  184. package/src/runtime/scopes/scope-kinds/comment-thread.ts +59 -0
  185. package/src/runtime/scopes/scope-kinds/field.ts +65 -0
  186. package/src/runtime/scopes/scope-kinds/heading.ts +84 -0
  187. package/src/runtime/scopes/scope-kinds/list-item.ts +77 -0
  188. package/src/runtime/scopes/scope-kinds/paragraph.ts +182 -0
  189. package/src/runtime/scopes/scope-kinds/revision.ts +62 -0
  190. package/src/runtime/scopes/scope-kinds/table-cell.ts +57 -0
  191. package/src/runtime/scopes/scope-kinds/table-row.ts +61 -0
  192. package/src/runtime/scopes/scope-kinds/table.ts +55 -0
  193. package/src/runtime/scopes/scope-range.ts +208 -0
  194. package/src/runtime/scopes/semantic-scope-types.ts +454 -0
  195. package/src/runtime/scopes/workflow-overlap.ts +92 -0
  196. package/src/runtime/selection/index.ts +1 -1
  197. package/src/runtime/structure-ops/fragment-insert.ts +1 -1
  198. package/src/runtime/structure-ops/index.ts +1 -1
  199. package/src/runtime/surface-projection.ts +232 -262
  200. package/src/runtime/units.ts +4 -2
  201. package/src/runtime/workflow/coordinator.ts +1348 -0
  202. package/src/runtime/workflow/derived-scope-resolver.ts +125 -0
  203. package/src/runtime/workflow/index.ts +25 -0
  204. package/src/runtime/workflow/markup-mode-policy.ts +98 -0
  205. package/src/runtime/{workflow-markup.ts → workflow/markup.ts} +6 -6
  206. package/src/runtime/workflow/metadata-persistence.ts +306 -0
  207. package/src/runtime/workflow/metadata-writer.ts +123 -0
  208. package/src/runtime/workflow/overlay-store.ts +690 -0
  209. package/src/runtime/workflow/projector.ts +127 -0
  210. package/src/runtime/{query-scopes.ts → workflow/query-scopes.ts} +3 -3
  211. package/src/runtime/{workflow-rail-segments.ts → workflow/rail/compose.ts} +60 -165
  212. package/src/runtime/workflow/rail/types.ts +198 -0
  213. package/src/runtime/workflow/scope-rail-composer.ts +39 -0
  214. package/src/runtime/{scope-resolver.ts → workflow/scope-resolver.ts} +3 -3
  215. package/src/runtime/workflow/scope-writer.ts +188 -0
  216. package/src/runtime/{tamper-gate.ts → workflow/tamper-gate.ts} +1 -1
  217. package/src/runtime/workflow/visibility-policy.ts +129 -0
  218. package/src/session/_sync-legacy.ts +66 -0
  219. package/src/session/export/embedded-reconstitute.ts +104 -0
  220. package/src/session/export/export-diagnostics.ts +85 -0
  221. package/src/session/export/export-validation.ts +110 -0
  222. package/src/session/export/index.ts +34 -0
  223. package/src/session/export/preservation-reattach.ts +30 -0
  224. package/src/session/export/serialize-dispatch.ts +165 -0
  225. package/src/session/export/stateful-export-pipeline.ts +432 -0
  226. package/src/session/export/stateful-export.ts +684 -0
  227. package/src/session/import/canonical-assembly.ts +227 -0
  228. package/src/session/import/diagnostics-session.ts +54 -0
  229. package/src/session/import/embedded-discovery.ts +225 -0
  230. package/src/session/import/embedded-offload.ts +337 -0
  231. package/src/session/import/import-diagnostics.ts +69 -0
  232. package/src/session/import/loader-types.ts +313 -0
  233. package/src/session/import/loader.ts +1834 -0
  234. package/src/session/import/normalize.ts +195 -0
  235. package/src/session/import/package-parts.ts +217 -0
  236. package/src/session/import/package-read.ts +195 -0
  237. package/src/session/import/parse-orchestration.ts +105 -0
  238. package/src/session/import/part-constants.ts +70 -0
  239. package/src/session/import/part-discovery.ts +94 -0
  240. package/src/session/import/preservation-index.ts +46 -0
  241. package/src/{runtime/read-only-diagnostics-runtime.ts → session/import/read-only-diagnostics.ts} +24 -3
  242. package/src/session/import/review-import.ts +508 -0
  243. package/src/session/import/styles-consolidation.ts +281 -0
  244. package/src/session/import/workflow-scope-import.ts +256 -0
  245. package/src/session/index.ts +37 -0
  246. package/src/session/session-state.ts +69 -0
  247. package/src/session/session.ts +532 -0
  248. package/src/session/shared/protection.ts +228 -0
  249. package/src/session/shared/session-utils.ts +82 -0
  250. package/src/session/types.ts +499 -0
  251. package/src/shell/chart-snapshots.ts +96 -0
  252. package/src/shell/media-previews.ts +85 -0
  253. package/src/shell/overlay-anchor-bridge.ts +53 -0
  254. package/src/shell/paste-adapter.ts +23 -0
  255. package/src/shell/ref-commands.ts +1697 -0
  256. package/src/shell/ref-utilities.ts +48 -0
  257. package/src/shell/search.ts +51 -0
  258. package/src/{ui/editor-runtime-boundary.ts → shell/session-bootstrap.ts} +243 -67
  259. package/src/shell/ui-subscriber-channels.ts +81 -0
  260. package/src/shell/use-collab-sync.ts +116 -0
  261. package/src/ui/WordReviewEditor.tsx +496 -2051
  262. package/src/ui/editor-shell-view.tsx +30 -1
  263. package/src/ui/editor-surface-controller.tsx +49 -1
  264. package/src/ui/headless/revision-decoration-model.ts +83 -0
  265. package/src/{ui-tailwind/chrome → ui/headless}/role-action-sets.ts +1 -1
  266. package/src/ui/headless/scoped-chrome-policy.ts +2 -2
  267. package/src/ui/headless/selection-tool-context.ts +1 -1
  268. package/src/ui/headless/selection-tool-resolver.ts +1 -1
  269. package/src/ui/runtime-shortcut-dispatch.ts +46 -1
  270. package/src/ui/ui-controller-factory.ts +221 -0
  271. package/src/ui-tailwind/chart/ChartSurface.tsx +2 -2
  272. package/src/ui-tailwind/chart/layout/legend-layout.ts +1 -1
  273. package/src/ui-tailwind/chart/layout/plot-area.ts +2 -2
  274. package/src/ui-tailwind/chart/layout/title-layout.ts +1 -1
  275. package/src/ui-tailwind/chart/render/area.tsx +3 -3
  276. package/src/ui-tailwind/chart/render/bar-column.tsx +3 -3
  277. package/src/ui-tailwind/chart/render/bubble.tsx +3 -3
  278. package/src/ui-tailwind/chart/render/combo.tsx +2 -2
  279. package/src/ui-tailwind/chart/render/data-labels.tsx +2 -2
  280. package/src/ui-tailwind/chart/render/font-metrics.ts +2 -2
  281. package/src/ui-tailwind/chart/render/line.tsx +3 -3
  282. package/src/ui-tailwind/chart/render/pie.tsx +6 -6
  283. package/src/ui-tailwind/chart/render/scatter.tsx +3 -3
  284. package/src/ui-tailwind/chart/render/svg-primitives.ts +3 -3
  285. package/src/ui-tailwind/chart/render/unsupported.tsx +2 -2
  286. package/src/ui-tailwind/chrome/build-context-menu-entries.ts +88 -0
  287. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +1 -1
  288. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +1 -1
  289. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +1 -1
  290. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +1 -1
  291. package/src/ui-tailwind/chrome/editor-action-registry.ts +553 -0
  292. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +182 -0
  293. package/src/ui-tailwind/chrome/local-surface-arbiter.ts +534 -0
  294. package/src/ui-tailwind/chrome/resolve-target-kind.ts +226 -0
  295. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +38 -4
  296. package/src/ui-tailwind/chrome/tw-context-band.tsx +125 -0
  297. package/src/ui-tailwind/chrome/tw-context-menu-portal.tsx +248 -0
  298. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +42 -1
  299. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +8 -7
  300. package/src/ui-tailwind/chrome/tw-selection-tool-blocked.tsx +38 -4
  301. package/src/ui-tailwind/chrome/tw-selection-tool-comment.tsx +104 -6
  302. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +66 -7
  303. package/src/ui-tailwind/chrome/tw-selection-tool-workflow.tsx +54 -8
  304. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +7 -1
  305. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +33 -0
  306. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +78 -1
  307. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +16 -8
  308. package/src/ui-tailwind/chrome/tw-workspace-chrome-host.tsx +276 -0
  309. package/src/ui-tailwind/chrome/use-context-menu-controller.ts +201 -0
  310. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +1 -1
  311. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +22 -4
  312. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +1 -1
  313. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +1 -1
  314. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +11 -5
  315. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +197 -3
  316. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +1 -1
  317. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +35 -6
  318. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +24 -16
  319. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +1 -1
  320. package/src/ui-tailwind/debug/README.md +57 -0
  321. package/src/ui-tailwind/debug/index.ts +3 -0
  322. package/src/ui-tailwind/debug/tw-debug-overlay.tsx +186 -0
  323. package/src/ui-tailwind/debug/tw-debug-presentation.tsx +80 -0
  324. package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +83 -0
  325. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +2 -2
  326. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +1 -1
  327. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +135 -10
  328. package/src/ui-tailwind/editor-surface/pm-decorations.ts +40 -13
  329. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +1 -1
  330. package/src/ui-tailwind/editor-surface/pm-schema.ts +1 -1
  331. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +3 -3
  332. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +1 -1
  333. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +2 -2
  334. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +91 -9
  335. package/src/ui-tailwind/editor-surface/shape-renderer.ts +1 -1
  336. package/src/ui-tailwind/editor-surface/surface-layer.ts +1 -1
  337. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +1 -1
  338. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +23 -6
  339. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +132 -22
  340. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +1 -1
  341. package/src/ui-tailwind/index.ts +0 -5
  342. package/src/ui-tailwind/overlay-anchor-bridge-context.tsx +33 -0
  343. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +66 -29
  344. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +25 -2
  345. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +15 -0
  346. package/src/ui-tailwind/review/tw-review-rail.tsx +92 -4
  347. package/src/ui-tailwind/review/tw-workflow-tab.tsx +1 -1
  348. package/src/ui-tailwind/review-workspace/page-chrome.ts +210 -0
  349. package/src/ui-tailwind/review-workspace/page-shell-metrics.ts +101 -0
  350. package/src/ui-tailwind/review-workspace/paragraph-layout.ts +115 -0
  351. package/src/ui-tailwind/review-workspace/selection-toolbar-placement.ts +97 -0
  352. package/src/ui-tailwind/review-workspace/tw-review-workspace-navigator.tsx +130 -0
  353. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +240 -0
  354. package/src/ui-tailwind/review-workspace/tw-review-workspace-rail.tsx +59 -0
  355. package/src/ui-tailwind/review-workspace/types.ts +408 -0
  356. package/src/ui-tailwind/review-workspace/use-chrome-policy.ts +104 -0
  357. package/src/ui-tailwind/review-workspace/use-derived-view-state.ts +151 -0
  358. package/src/ui-tailwind/review-workspace/use-diagnostics-signal.ts +70 -0
  359. package/src/ui-tailwind/review-workspace/use-grabbed-segment-offsets.ts +40 -0
  360. package/src/ui-tailwind/review-workspace/use-layout-facet-render-signal.ts +55 -0
  361. package/src/ui-tailwind/review-workspace/use-page-markers.ts +130 -0
  362. package/src/ui-tailwind/review-workspace/use-pm-surface-capture.ts +60 -0
  363. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +63 -0
  364. package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +170 -0
  365. package/src/ui-tailwind/review-workspace/use-scroll-root-capture.ts +28 -0
  366. package/src/ui-tailwind/review-workspace/use-selection-toolbar-placement.ts +113 -0
  367. package/src/ui-tailwind/review-workspace/use-shell-selection-anchor-bridge.ts +120 -0
  368. package/src/ui-tailwind/review-workspace/use-status-bar-page-facts.ts +55 -0
  369. package/src/ui-tailwind/review-workspace/use-viewport-dimensions.ts +43 -0
  370. package/src/ui-tailwind/review-workspace/use-workspace-arbiter.ts +25 -0
  371. package/src/ui-tailwind/review-workspace/use-workspace-composition.ts +86 -0
  372. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +150 -0
  373. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  374. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -2
  375. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +61 -98
  376. package/src/ui-tailwind/tw-review-workspace.tsx +521 -1802
  377. package/src/ui-tailwind/ui-api-context.tsx +43 -0
  378. package/src/ui-tailwind/ui-shell-channels-context.tsx +49 -0
  379. package/src/validation/compatibility-engine.ts +6 -6
  380. package/src/runtime/styles-cascade.ts +0 -33
  381. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +0 -85
  382. /package/src/runtime/{page-number-format.ts → formatting/field/page-number-format.ts} +0 -0
  383. /package/src/runtime/{ai-action-policy.ts → workflow/ai-action-policy.ts} +0 -0
  384. /package/src/runtime/{scope-tag-registry.ts → workflow/scope-tag-registry.ts} +0 -0
@@ -1,4797 +1,36 @@
1
- import type {
2
- CompatibilityReport as PublicCompatibilityReport,
3
- EditorError,
4
- EditorHostAdapter,
5
- EditorSessionState,
6
- EditorSurfaceSnapshot,
7
- EditorWarning as PublicEditorWarning,
8
- EditorAnchorProjection as PublicEditorAnchorProjection,
9
- ExportDocxOptions,
10
- ExportResult,
11
- PersistedEditorSnapshot,
12
- ProtectionRange,
13
- ProtectionSnapshot,
14
- WorkflowMetadataSnapshot,
15
- WorkflowOverlay,
16
- WorkflowScope,
17
- WorkflowScopeMetadataField,
18
- WorkflowWorkItem,
19
- } from "../api/public-types.ts";
20
- import { editorSessionStateFromPersistedSnapshot } from "../api/session-state.ts";
21
- import type {
22
- CanonicalDocumentEnvelope,
23
- CompatibilityFeatureEntry as InternalCompatibilityFeatureEntry,
24
- CompatibilityReport as InternalCompatibilityReport,
25
- CommentThreadRecord,
26
- EditorError as InternalEditorError,
27
- RevisionRecord as RuntimeRevisionRecord,
28
- EditorWarning as InternalEditorWarning,
29
- } from "../core/state/editor-state.ts";
30
- import {
31
- createCanonicalDocumentId,
32
- createDefaultCanonicalDocument,
33
- createSelectionSnapshot,
34
- } from "../core/state/editor-state.ts";
35
- // NOTE: docx-session.ts is intentionally an integration orchestrator, not a
36
- // pure io module. It coordinates io (OOXML parse/export) with runtime
37
- // (surface projection, cache envelopes, read-only diagnostics) and
38
- // presentation. The runtime imports below (surface-projection,
39
- // prerender/cache-envelope, read-only-diagnostics-runtime) are load-bearing
40
- // and violate the nominal io→runtime boundary on purpose. The proper
41
- // long-term fix is to relocate this file to src/session/ (see
42
- // docs/plans/architecture-lane.md §F2). Do NOT add more runtime imports
43
- // here without first reading that deferral rationale.
44
- import { createEditorSurfaceSnapshot } from "../runtime/surface-projection.ts";
45
- import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
46
- import {
47
- createDetachedAnchor,
48
- storyTargetsEqual,
49
- type EditorAnchorProjection as InternalEditorAnchorProjection,
50
- } from "../core/selection/mapping.ts";
51
- import { DOCX_MIME_TYPE } from "./opc/docx-package.ts";
52
- import { readOpcPackage, type OpcPackage } from "./opc/package-reader.ts";
53
- import {
54
- parseMainDocumentXml,
55
- type ParsedBlockNode,
56
- type ParsedInlineNode,
57
- type ParsedPermStartInlineNode,
58
- } from "./ooxml/parse-main-document.ts";
59
- import {
60
- normalizeParsedTextDocument,
61
- normalizeParsedTextDocumentAsync,
62
- } from "./normalize/normalize-text.ts";
63
- import { createChartPartLookup, resolveChartPreviewsForDocument, scheduleChartPreviewResolution } from "./chart-preview-resolver.ts";
64
- import { type LoadScheduler } from "./load-scheduler.ts";
65
- import type { CacheEnvelope } from "../runtime/prerender/cache-envelope.ts";
66
- import {
67
- CONTENT_TYPES_PATH,
68
- PACKAGE_RELATIONSHIPS_PATH,
69
- getRelationshipsPartPath,
70
- normalizePartPath,
71
- resolveRelationshipTarget,
72
- type OpcRelationship,
73
- } from "./ooxml/part-manifest.ts";
74
- import {
75
- buildWorkflowPayloadParts,
76
- getDocumentBackedWorkflowMetadata,
77
- parseWorkflowPayloadEnvelopeFromPackage,
78
- resolvePayloadPartPath,
79
- resolveWorkflowPayloadPartPaths,
80
- WORKFLOW_PAYLOAD_CONTENT_TYPE,
81
- WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE,
82
- WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
83
- WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE,
84
- WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
85
- WORKFLOW_PAYLOAD_RELATIONSHIP_TYPE,
86
- } from "./ooxml/workflow-payload.ts";
87
- import {
88
- classifyCorruptPackageError,
89
- createBrokenRelationshipIssue,
90
- createMissingPartIssue,
91
- } from "./opc/corrupt-package.ts";
92
- import { buildAppPropertiesXml } from "./export/build-app-properties-xml.ts";
93
- import { createExportSession } from "./export/export-session.ts";
94
- import { serializeMainDocument } from "./export/serialize-main-document.ts";
95
- import {
96
- serializeSettingsXml,
97
- WORD_SETTINGS_CONTENT_TYPE,
98
- } from "./export/serialize-settings.ts";
99
- import {
100
- parseRevisionsFromDocumentXml,
101
- parseRevisionsFromStoryXml,
102
- type ParsedRevisionsResult,
103
- } from "./ooxml/parse-revisions.ts";
104
- import { parseCommentsFromOoxml } from "./ooxml/parse-comments.ts";
105
- import { parseNumberingXml } from "./ooxml/parse-numbering.ts";
106
- import {
107
- createCommentExportIdMap,
108
- mapParagraphBoundaries,
109
- serializeCommentAnchorsIntoDocumentXml,
110
- serializeMergedCommentsXml,
111
- } from "./export/serialize-comments.ts";
112
- import { splitDocumentAtReviewBoundaries } from "./export/split-review-boundaries.ts";
113
- import { splitStoryBlocksForRuntimeRevisions } from "./export/split-story-blocks-for-runtime-revisions.ts";
114
- import {
115
- serializeRuntimeRevisionsIntoDocumentXml,
116
- serializeRuntimeRevisionsIntoStoryXml,
117
- } from "./export/serialize-runtime-revisions.ts";
118
- import { createCommentStoreFromRuntimeComments } from "../review/store/runtime-comment-store.ts";
119
- import type { CommentThread } from "../review/store/comment-store.ts";
120
- import type { RevisionRecord as ReviewRevisionRecord } from "../review/store/revision-types.ts";
121
- import { getRevisionActionability } from "../review/store/revision-types.ts";
122
- import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
123
- import {
124
- createPackageImportDiagnostics,
125
- createValidationImportDiagnostics,
126
- type ImportDiagnosticsResult,
127
- } from "../validation/import-diagnostics.ts";
128
- import type {
129
- BlockNode,
130
- FootnoteCollection,
131
- HeaderDocument,
132
- FooterDocument,
133
- InlineNode,
134
- MediaCatalog,
135
- NumberingCatalog,
136
- OpaqueFragmentRecord,
137
- PreservedPackagePart,
138
- SubPartsCatalog,
139
- } from "../model/canonical-document.ts";
140
- import { createCanonicalDocumentSignature } from "../model/canonical-document.ts";
141
- import type { CanonicalDocument } from "../model/canonical-document.ts";
142
- import type {
143
- CommentImportDiagnostic,
144
- ImportedCommentDefinition,
145
- ParsedCommentsResult,
146
- } from "./ooxml/parse-comments.ts";
147
- import { createReadOnlyDiagnosticsRuntime } from "../runtime/read-only-diagnostics-runtime.ts";
148
- import {
149
- WORD_NUMBERING_CONTENT_TYPE,
150
- hasSerializableNumberingEntries,
151
- serializeNumberingXml,
152
- } from "./export/serialize-numbering.ts";
153
- import {
154
- parseHeaderFooterReferences,
155
- parseHeaderXml,
156
- parseFooterXml,
157
- } from "./ooxml/parse-headers-footers.ts";
158
- import { parseFootnotesXml, parseEndnotesXml } from "./ooxml/parse-footnotes.ts";
159
- import { materializeCanonicalTheme, parseThemeXml, resolveTheme } from "./ooxml/parse-theme.ts";
160
- import { parseSettingsXml } from "./ooxml/parse-settings.ts";
161
- import { parseStylesXml, type ParseStylesResult } from "./ooxml/parse-styles.ts";
162
- import { parseFontTable } from "./ooxml/parse-font-table.ts";
163
- import {
164
- serializeHeaderXml,
165
- serializeHeaderXmlWithRevisions,
166
- serializeFooterXml,
167
- serializeFooterXmlWithRevisions,
168
- WORD_HEADER_CONTENT_TYPE,
169
- WORD_FOOTER_CONTENT_TYPE,
170
- } from "./export/serialize-headers-footers.ts";
171
- import {
172
- serializeFootnotesXml,
173
- serializeEndnotesXml,
174
- WORD_FOOTNOTES_CONTENT_TYPE,
175
- WORD_ENDNOTES_CONTENT_TYPE,
176
- } from "./export/serialize-footnotes.ts";
177
- import { createPersistedSourcePackage } from "./source-package-provenance.ts";
178
- import { validatePersistedEditorSnapshot } from "../model/snapshot.ts";
179
- import {
180
- createSyntheticDocxNullNumberingCatalog,
181
- DOCX_NULL_NUMBERING_INSTANCE_ID,
182
- } from "./ooxml/numbering-sentinels.ts";
183
-
184
- const MAIN_DOCUMENT_PATH = "/word/document.xml";
185
- const NUMBERING_PART_PATH = "/word/numbering.xml";
186
- const COMMENTS_PART_PATH = "/word/comments.xml";
187
- const COMMENTS_EXTENDED_PART_PATH = "/word/commentsExtended.xml";
188
- const COMMENTS_IDS_PART_PATH = "/word/commentsIds.xml";
189
- const PEOPLE_PART_PATH = "/word/people.xml";
190
- const APP_PROPERTIES_PART_PATH = "/docProps/app.xml";
191
- const CORE_PROPERTIES_PART_PATH = "/docProps/core.xml";
192
- const MAIN_DOCUMENT_CONTENT_TYPE =
193
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
194
- const APP_PROPERTIES_CONTENT_TYPE =
195
- "application/vnd.openxmlformats-officedocument.extended-properties+xml";
196
- const CORE_PROPERTIES_CONTENT_TYPE =
197
- "application/vnd.openxmlformats-package.core-properties+xml";
198
- const NUMBERING_RELATIONSHIP_TYPE =
199
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
200
- const COMMENTS_CONTENT_TYPE =
201
- "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml";
202
- const COMMENTS_EXTENDED_CONTENT_TYPE =
203
- "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml";
204
- const COMMENTS_IDS_CONTENT_TYPE =
205
- "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml";
206
- const PEOPLE_CONTENT_TYPE =
207
- "application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml";
208
- const COMMENTS_RELATIONSHIP_TYPE =
209
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
210
- const COMMENTS_EXTENDED_RELATIONSHIP_TYPE =
211
- "http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
212
- const COMMENTS_IDS_RELATIONSHIP_TYPE =
213
- "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds";
214
- const PEOPLE_RELATIONSHIP_TYPE =
215
- "http://schemas.microsoft.com/office/2011/relationships/people";
216
- const APP_PROPERTIES_RELATIONSHIP_TYPE =
217
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
218
- const CORE_PROPERTIES_RELATIONSHIP_TYPE =
219
- "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
220
- const OFFICE_DOCUMENT_RELATIONSHIP_TYPE =
221
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
222
- const HEADER_RELATIONSHIP_TYPE =
223
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
224
- const FOOTER_RELATIONSHIP_TYPE =
225
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
226
- const FOOTNOTES_RELATIONSHIP_TYPE =
227
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
228
- const ENDNOTES_RELATIONSHIP_TYPE =
229
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
230
- const SETTINGS_RELATIONSHIP_TYPE =
231
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
232
- const STYLES_RELATIONSHIP_TYPE =
233
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
234
- const STYLES_PART_PATH = "/word/styles.xml";
235
- const FONT_TABLE_RELATIONSHIP_TYPE =
236
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable";
237
- const FONT_TABLE_PART_PATH = "/word/fontTable.xml";
238
- const FOOTNOTES_PART_PATH = "/word/footnotes.xml";
239
- const ENDNOTES_PART_PATH = "/word/endnotes.xml";
240
- const SETTINGS_PART_PATH = "/word/settings.xml";
241
-
242
- interface LoadDocxEditorSessionOptions {
243
- documentId: string;
244
- sourceLabel?: string;
245
- bytes: Uint8Array | ArrayBuffer;
246
- editorBuild?: string;
247
- /**
248
- * Fastload P2: optional instrumentation callback invoked once per
249
- * completed load stage. Stage ordering: opc → body →
250
- * styles-numbering-comments → skeleton-ready. `durationMs` is
251
- * measured against `performance.now()` boundaries inside the loader.
252
- *
253
- * Pure instrumentation — has no effect on returned session shape.
254
- * Safe to leave undefined; existing callers observe no behavior
255
- * change.
256
- */
257
- onLoadStage?: (stage: import("../api/public-types.ts").LoadStage, durationMs: number) => void;
258
- /**
259
- * Stage 0B.1: host-adapter surface. The sync loader accepts the option for
260
- * API symmetry with {@link LoadDocxEditorSessionAsyncOptions} but does not
261
- * invoke `renderChartPreview` — chart-preview synthesis is asynchronous, so
262
- * hosts that want preview bitmaps must call {@link loadDocxEditorSessionAsync}.
263
- * Other adapter methods (load/saveSession/logEvent) are not consumed by the
264
- * loader itself and are carried through unchanged.
265
- */
266
- hostAdapter?: EditorHostAdapter;
267
- }
268
-
269
- export interface LoadedDocxEditorSession {
270
- initialSessionState: EditorSessionState;
271
- initialSnapshot: PersistedEditorSnapshot;
272
- fatalError?: EditorError;
273
- readOnly: boolean;
274
- protectionSnapshot: ProtectionSnapshot;
275
- exportDocx: (
276
- sessionState: EditorSessionState | PersistedEditorSnapshot,
277
- options?: ExportDocxOptions,
278
- ) => Promise<ExportResult>;
279
- /** Schema 1.2 — editorState block parsed from the 1.2 payload, if present. */
280
- initialEditorStatePayload?: import("./ooxml/workflow-payload.ts").EditorStatePayload;
281
- }
282
-
283
- interface ImportedDocxState {
284
- sourceBytes: Uint8Array;
285
- sourcePackage: OpcPackage;
286
- sourceDocumentXml: string;
287
- sourceDocumentPartPath: string;
288
- sourceDocumentRelationships: readonly OpcRelationship[];
289
- sourceDocumentAttributes: Record<string, string>;
290
- sourceNumberingPartPath?: string;
291
- sourceNumberingRelationshipId?: string;
292
- /**
293
- * Resolved `/word/settings.xml` part path when the source package carried
294
- * one. Threaded through to the export path so the settings serializer can
295
- * call `replaceOwnedPart` with the right relationship target.
296
- */
297
- sourceSettingsPartPath?: string;
298
- /**
299
- * Original settings.xml bytes decoded as UTF-8. Passed to
300
- * `serializeSettingsXml(settings, sourceXml)` as the graft source so
301
- * unmodelled top-level children (`<w:defaultTabStop>`,
302
- * `<w:documentProtection>`, mail-merge state, etc.) survive verbatim
303
- * through round-trip. Undefined when the source package lacked a
304
- * settings part.
305
- */
306
- sourceSettingsXml?: string;
307
- sourceCommentsPartPath?: string;
308
- sourceCommentsRelationshipId?: string;
309
- sourceCommentsRootTag?: string;
310
- sourceCommentsExtendedPartPath?: string;
311
- sourceCommentsExtendedRelationshipId?: string;
312
- sourceCommentsExtendedRootTag?: string;
313
- sourceCommentsIdsPartPath?: string;
314
- sourceCommentsIdsRelationshipId?: string;
315
- sourceCommentsIdsRootTag?: string;
316
- sourcePeoplePartPath?: string;
317
- sourcePeopleRelationshipId?: string;
318
- sourcePeopleRootTag?: string;
319
- sourcePeopleAuthors: readonly string[];
320
- protectionSnapshot: ProtectionSnapshot;
321
- preservedCommentDefinitions: readonly ImportedCommentDefinition[];
322
- blockingCommentDiagnostics: readonly CommentImportDiagnostic[];
323
- initialCanonicalSignature: string;
324
- sourceSubPartPaths: {
325
- headers: Array<{ partPath: string; relationshipId: string }>;
326
- footers: Array<{ partPath: string; relationshipId: string }>;
327
- footnotesPartPath?: string;
328
- footnotesRelationshipId?: string;
329
- endnotesPartPath?: string;
330
- endnotesRelationshipId?: string;
331
- themePartPath?: string;
332
- themeRelationshipId?: string;
333
- };
334
- }
335
-
336
- export interface NormalizedImportedCommentsResult extends ParsedCommentsResult {
337
- preservedDefinitions: readonly ImportedCommentDefinition[];
338
- }
339
-
340
- const BLOCKING_COMMENT_DIAGNOSTIC_CODES = new Set<CommentImportDiagnostic["code"]>([
341
- "missing_comment_definition",
342
- "missing_anchor_reference",
343
- "multi_paragraph_anchor_preserve_only",
344
- "opaque_anchor_preserve_only",
345
- "preserve_only_revision_overlap",
346
- ]);
347
-
348
- interface StageEmitter {
349
- emit(stage: import("../api/public-types.ts").LoadStage): void;
350
- }
351
-
352
- /**
353
- * Fastload P2 (hoisted in P6): build a stage-event emitter. When no callback
354
- * is supplied the emitter is a zero-cost no-op. Shared between the sync and
355
- * async load orchestrators so both paths emit `load-stage` events at the
356
- * same four boundaries: `opc` → `body` → `styles-numbering-comments` →
357
- * `skeleton-ready`.
358
- */
359
- function createStageEmitter(
360
- onStage: LoadDocxEditorSessionOptions["onLoadStage"],
361
- ): StageEmitter {
362
- if (!onStage) {
363
- return {
364
- emit() {
365
- /* no-op */
366
- },
367
- };
368
- }
369
- let cursor = loadStageNow();
370
- return {
371
- emit(stage) {
372
- const nextMark = loadStageNow();
373
- onStage(stage, nextMark - cursor);
374
- cursor = nextMark;
375
- },
376
- };
377
- }
378
-
379
- export function loadDocxEditorSession(
380
- options: LoadDocxEditorSessionOptions,
381
- ): LoadedDocxEditorSession {
382
- const editorBuild =
383
- typeof options.editorBuild === "string" && options.editorBuild.length > 0
384
- ? options.editorBuild
385
- : "dev";
386
- const sourceBytes = toUint8Array(options.bytes);
387
-
388
- const stages = createStageEmitter(options.onLoadStage);
389
-
390
- let sourcePackage: OpcPackage;
391
-
392
- try {
393
- sourcePackage = readOpcPackage(sourceBytes);
394
- } catch (error) {
395
- return createDiagnosticsSession(
396
- options,
397
- createPackageImportDiagnostics({
398
- issue: classifyCorruptPackageError(error),
399
- }),
400
- );
401
- }
402
- stages.emit("opc");
403
- const embeddedWorkflowPayload = parseWorkflowPayloadEnvelopeFromPackage(sourcePackage);
404
- const embeddedWorkflowMetadata = embeddedWorkflowPayload?.workflowMetadata;
405
- const embeddedWorkflowOverlay = embeddedWorkflowPayload?.workflowOverlay;
406
-
407
- const mainDocumentPath = resolveMainDocumentPartPath(sourcePackage);
408
- const brokenRelationshipIssues = collectBrokenInternalRelationshipIssues(
409
- sourcePackage,
410
- mainDocumentPath,
411
- );
412
- if (brokenRelationshipIssues.length > 0) {
413
- return createDiagnosticsSession(
414
- options,
415
- createPackageImportDiagnostics({
416
- issue: {
417
- ...brokenRelationshipIssues[0],
418
- message: summarizeBrokenRelationshipIssues(brokenRelationshipIssues),
419
- details: {
420
- issueCount: brokenRelationshipIssues.length,
421
- targets: brokenRelationshipIssues.map((issue) => issue.targetPartPath).filter(Boolean),
422
- },
423
- },
424
- }),
425
- );
426
- }
427
-
428
- if (!mainDocumentPath) {
429
- return createDiagnosticsSession(
430
- options,
431
- createPackageImportDiagnostics({
432
- issue: createMissingPartIssue(MAIN_DOCUMENT_PATH),
433
- }),
434
- );
435
- }
436
-
437
- const documentPart = sourcePackage.parts.get(mainDocumentPath);
438
- if (!documentPart) {
439
- return createDiagnosticsSession(
440
- options,
441
- createPackageImportDiagnostics({
442
- issue: createMissingPartIssue(mainDocumentPath),
443
- }),
444
- );
445
- }
446
- if (documentPart.contentType !== MAIN_DOCUMENT_CONTENT_TYPE) {
447
- return createDiagnosticsSession(
448
- options,
449
- createValidationImportDiagnostics({
450
- message: `DOCX main document part ${mainDocumentPath} must use content type ${MAIN_DOCUMENT_CONTENT_TYPE}.`,
451
- }),
452
- );
453
- }
454
-
455
- try {
456
- const sourceDocumentXml = decodeUtf8(documentPart.bytes);
457
- const importedRevisions = parseRevisionsFromDocumentXml(sourceDocumentXml);
458
- const numberingPartPath = resolveDocumentRelatedPartPath(
459
- sourcePackage,
460
- mainDocumentPath,
461
- documentPart.relationships,
462
- NUMBERING_RELATIONSHIP_TYPE,
463
- NUMBERING_PART_PATH,
464
- );
465
- const parsedNumbering = numberingPartPath
466
- ? parseNumberingXml(
467
- decodeUtf8(sourcePackage.parts.get(numberingPartPath)?.bytes ?? new Uint8Array()),
468
- {
469
- relationships: sourcePackage.parts.get(numberingPartPath)?.relationships,
470
- partPath: numberingPartPath,
471
- },
472
- )
473
- : createEmptyNumberingCatalog();
474
- const mediaParts = collectInlineMediaParts(sourcePackage);
475
- const chartPartLookup = createChartPartLookup(
476
- sourcePackage,
477
- mainDocumentPath,
478
- documentPart.relationships,
479
- );
480
- const parsedDocument = parseMainDocumentXml(
481
- sourceDocumentXml,
482
- documentPart.relationships,
483
- mediaParts,
484
- mainDocumentPath,
485
- chartPartLookup,
486
- );
487
- const protectionRanges = extractProtectionRanges(parsedDocument.blocks);
488
- const normalizedDocument = normalizeParsedTextDocument(
489
- parsedDocument,
490
- mainDocumentPath,
491
- );
492
- stages.emit("body");
493
- const commentsPartPath = resolveCommentsPartPath(
494
- sourcePackage,
495
- mainDocumentPath,
496
- documentPart.relationships,
497
- );
498
- const commentsExtendedPartPath = resolveDocumentRelatedPartPath(
499
- sourcePackage,
500
- mainDocumentPath,
501
- documentPart.relationships,
502
- COMMENTS_EXTENDED_RELATIONSHIP_TYPE,
503
- COMMENTS_EXTENDED_PART_PATH,
504
- );
505
- const commentsIdsPartPath = resolveDocumentRelatedPartPath(
506
- sourcePackage,
507
- mainDocumentPath,
508
- documentPart.relationships,
509
- COMMENTS_IDS_RELATIONSHIP_TYPE,
510
- COMMENTS_IDS_PART_PATH,
511
- );
512
- const peoplePartPath = resolveDocumentRelatedPartPath(
513
- sourcePackage,
514
- mainDocumentPath,
515
- documentPart.relationships,
516
- PEOPLE_RELATIONSHIP_TYPE,
517
- PEOPLE_PART_PATH,
518
- );
519
- const parsedComments = commentsPartPath
520
- ? parseCommentsFromOoxml(
521
- sourceDocumentXml,
522
- {
523
- commentsXml: decodeUtf8(sourcePackage.parts.get(commentsPartPath)?.bytes ?? new Uint8Array()),
524
- commentsExtendedXml: decodeUtf8(
525
- sourcePackage.parts.get(commentsExtendedPartPath ?? "")?.bytes ?? new Uint8Array(),
526
- ),
527
- commentsIdsXml: decodeUtf8(
528
- sourcePackage.parts.get(commentsIdsPartPath ?? "")?.bytes ?? new Uint8Array(),
529
- ),
530
- peopleXml: decodeUtf8(
531
- sourcePackage.parts.get(peoplePartPath ?? "")?.bytes ?? new Uint8Array(),
532
- ),
533
- },
534
- )
535
- : {
536
- threads: [] as CommentThread[],
537
- diagnostics: [] as CommentImportDiagnostic[],
538
- definitions: [] as ImportedCommentDefinition[],
539
- sourceRootTag: undefined,
540
- sourceExtendedRootTag: undefined,
541
- sourceIdsRootTag: undefined,
542
- sourcePeopleRootTag: undefined,
543
- peopleAuthors: [] as string[],
544
- };
545
- const normalizedRevisions = normalizeImportedRevisionRecords(
546
- importedRevisions,
547
- normalizedDocument.content,
548
- normalizedDocument.preservation.opaqueFragments,
549
- );
550
- const normalizedComments = normalizeImportedCommentThreads(
551
- parsedComments,
552
- normalizedDocument.preservation.opaqueFragments,
553
- normalizedRevisions.revisions,
554
- );
555
- stages.emit("styles-numbering-comments");
556
- const importedStoryRevisions: ReviewRevisionRecord[] = [];
557
- const importedStoryRevisionDiagnostics: ParsedRevisionsResult["diagnostics"] = [];
558
- const subPartOpaqueState = createSubPartOpaqueImportState(
559
- normalizedDocument.preservation.opaqueFragments,
560
- normalizedDocument.diagnostics.warnings,
561
- );
562
- // ---- Parse sub-parts: headers, footers, footnotes, endnotes, theme ----
563
- const headerFooterRefs = parseHeaderFooterReferences(sourceDocumentXml);
564
- const parsedHeaders: HeaderDocument[] = [];
565
- const parsedFooters: FooterDocument[] = [];
566
- const sourceHeaderPaths: Array<{ partPath: string; relationshipId: string }> = [];
567
- const sourceFooterPaths: Array<{ partPath: string; relationshipId: string }> = [];
568
- const seenSubPartKeys = new Set<string>();
569
-
570
- for (const ref of headerFooterRefs) {
571
- const dedupeKey = `${ref.kind}:${ref.variant}:${ref.relationshipId}`;
572
- if (seenSubPartKeys.has(dedupeKey)) {
573
- continue;
574
- }
575
- seenSubPartKeys.add(dedupeKey);
576
-
577
- const relationship = documentPart.relationships.find(
578
- (r) => r.id === ref.relationshipId && r.targetMode === "internal",
579
- );
580
- if (!relationship) {
581
- continue;
582
- }
583
-
584
- const partPath = resolveRelationshipTarget(mainDocumentPath, relationship);
585
- const part = sourcePackage.parts.get(partPath);
586
- const partBytes = part?.bytes;
587
- if (!partBytes) {
588
- continue;
589
- }
590
-
591
- const xml = decodeUtf8(partBytes);
592
- const subPartRelationships = part?.relationships ?? [];
593
- const subPartChartPartLookup = createChartPartLookup(
594
- sourcePackage,
595
- partPath,
596
- subPartRelationships,
597
- );
598
- if (ref.kind === "header") {
599
- const parsedHeaderRevisions = parseRevisionsFromStoryXml(xml);
600
- const parsed = parseHeaderXml(xml, {
601
- relationships: subPartRelationships,
602
- mediaParts,
603
- sourcePartPath: partPath,
604
- chartPartLookup: subPartChartPartLookup,
605
- });
606
- parsedHeaders.push({
607
- variant: ref.variant,
608
- partPath,
609
- relationshipId: ref.relationshipId,
610
- ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
611
- blocks: normalizeSubPartOpaqueBlocks(
612
- parsed.blocks,
613
- normalizedDocument.preservation.opaqueFragments,
614
- normalizedDocument.diagnostics.warnings,
615
- partPath,
616
- subPartOpaqueState,
617
- ),
618
- });
619
- importedStoryRevisions.push(
620
- ...parsedHeaderRevisions.revisions.map((revision): ReviewRevisionRecord => ({
621
- ...revision,
622
- metadata: {
623
- ...revision.metadata,
624
- storyTarget: {
625
- kind: "header" as const,
626
- relationshipId: ref.relationshipId,
627
- variant: ref.variant,
628
- ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
629
- },
630
- },
631
- })),
632
- );
633
- importedStoryRevisionDiagnostics.push(...parsedHeaderRevisions.diagnostics);
634
- sourceHeaderPaths.push({ partPath, relationshipId: ref.relationshipId });
635
- } else {
636
- const parsedFooterRevisions = parseRevisionsFromStoryXml(xml);
637
- const parsed = parseFooterXml(xml, {
638
- relationships: subPartRelationships,
639
- mediaParts,
640
- sourcePartPath: partPath,
641
- chartPartLookup: subPartChartPartLookup,
642
- });
643
- parsedFooters.push({
644
- variant: ref.variant,
645
- partPath,
646
- relationshipId: ref.relationshipId,
647
- ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
648
- blocks: normalizeSubPartOpaqueBlocks(
649
- parsed.blocks,
650
- normalizedDocument.preservation.opaqueFragments,
651
- normalizedDocument.diagnostics.warnings,
652
- partPath,
653
- subPartOpaqueState,
654
- ),
655
- });
656
- importedStoryRevisions.push(
657
- ...parsedFooterRevisions.revisions.map((revision): ReviewRevisionRecord => ({
658
- ...revision,
659
- metadata: {
660
- ...revision.metadata,
661
- storyTarget: {
662
- kind: "footer" as const,
663
- relationshipId: ref.relationshipId,
664
- variant: ref.variant,
665
- ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
666
- },
667
- },
668
- })),
669
- );
670
- importedStoryRevisionDiagnostics.push(...parsedFooterRevisions.diagnostics);
671
- sourceFooterPaths.push({ partPath, relationshipId: ref.relationshipId });
672
- }
673
- }
674
-
675
- const footnotesPartPath = resolveDocumentRelatedPartPath(
676
- sourcePackage,
677
- mainDocumentPath,
678
- documentPart.relationships,
679
- FOOTNOTES_RELATIONSHIP_TYPE,
680
- FOOTNOTES_PART_PATH,
681
- );
682
- const footnotesRelationshipId = documentPart.relationships.find(
683
- (r) => r.type === FOOTNOTES_RELATIONSHIP_TYPE && r.targetMode === "internal",
684
- )?.id;
685
- const endnotesPartPath = resolveDocumentRelatedPartPath(
686
- sourcePackage,
687
- mainDocumentPath,
688
- documentPart.relationships,
689
- ENDNOTES_RELATIONSHIP_TYPE,
690
- ENDNOTES_PART_PATH,
691
- );
692
- const endnotesRelationshipId = documentPart.relationships.find(
693
- (r) => r.type === ENDNOTES_RELATIONSHIP_TYPE && r.targetMode === "internal",
694
- )?.id;
695
-
696
- let footnoteCollection: FootnoteCollection | undefined;
697
- if (footnotesPartPath) {
698
- footnoteCollection = parseFootnotesXml(
699
- decodeUtf8(sourcePackage.parts.get(footnotesPartPath)?.bytes ?? new Uint8Array()),
700
- );
701
- normalizeFootnoteCollectionOpaqueBlocks(
702
- footnoteCollection,
703
- "footnote",
704
- normalizedDocument.preservation.opaqueFragments,
705
- normalizedDocument.diagnostics.warnings,
706
- footnotesPartPath,
707
- subPartOpaqueState,
708
- );
709
- }
710
- if (endnotesPartPath) {
711
- footnoteCollection = parseEndnotesXml(
712
- decodeUtf8(sourcePackage.parts.get(endnotesPartPath)?.bytes ?? new Uint8Array()),
713
- footnoteCollection,
714
- );
715
- normalizeFootnoteCollectionOpaqueBlocks(
716
- footnoteCollection,
717
- "endnote",
718
- normalizedDocument.preservation.opaqueFragments,
719
- normalizedDocument.diagnostics.warnings,
720
- endnotesPartPath,
721
- subPartOpaqueState,
722
- );
723
- }
724
-
725
- const themeRelationship = documentPart.relationships.find(
726
- (r) => r.type === "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" &&
727
- r.targetMode === "internal",
728
- );
729
- const themePartPath = themeRelationship
730
- ? resolveRelationshipTarget(mainDocumentPath, themeRelationship)
731
- : undefined;
732
- const parsedTheme =
733
- themePartPath && sourcePackage.parts.has(themePartPath)
734
- ? parseThemeXml(
735
- decodeUtf8(sourcePackage.parts.get(themePartPath)?.bytes ?? new Uint8Array()),
736
- )
737
- : undefined;
738
- const resolvedTheme = parsedTheme ? resolveTheme(parsedTheme) : undefined;
739
- const settingsPartPath = resolveDocumentRelatedPartPath(
740
- sourcePackage,
741
- mainDocumentPath,
742
- documentPart.relationships,
743
- SETTINGS_RELATIONSHIP_TYPE,
744
- SETTINGS_PART_PATH,
745
- );
746
- const parsedSettings =
747
- settingsPartPath && sourcePackage.parts.has(settingsPartPath)
748
- ? parseSettingsXml(
749
- decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array()),
750
- )
751
- : undefined;
752
- const canonicalTheme =
753
- parsedTheme !== undefined
754
- ? materializeCanonicalTheme(
755
- parsedTheme,
756
- parsedSettings?.clrSchemeMapping ?? {},
757
- )
758
- : undefined;
759
- const settingsXmlForProtection =
760
- settingsPartPath && sourcePackage.parts.has(settingsPartPath)
761
- ? decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array())
762
- : "";
763
- const documentProtection = extractDocumentProtection(settingsXmlForProtection);
764
- const importedProtectionSnapshot = buildProtectionSnapshot(documentProtection, protectionRanges);
765
-
766
- // ---- Parse styles.xml for canonical style catalog ----
767
- const stylesPartPath = resolveDocumentRelatedPartPath(
768
- sourcePackage,
769
- mainDocumentPath,
770
- documentPart.relationships,
771
- STYLES_RELATIONSHIP_TYPE,
772
- STYLES_PART_PATH,
773
- );
774
- const parsedStyles =
775
- stylesPartPath && sourcePackage.parts.has(stylesPartPath)
776
- ? parseStylesXml(
777
- decodeUtf8(sourcePackage.parts.get(stylesPartPath)?.bytes ?? new Uint8Array()),
778
- )
779
- : parseStylesXml("");
780
-
781
- // ---- Parse fontTable.xml for canonical font catalog ----
782
- const fontTablePartPath = resolveDocumentRelatedPartPath(
783
- sourcePackage,
784
- mainDocumentPath,
785
- documentPart.relationships,
786
- FONT_TABLE_RELATIONSHIP_TYPE,
787
- FONT_TABLE_PART_PATH,
788
- );
789
- const parsedFontTable =
790
- fontTablePartPath && sourcePackage.parts.has(fontTablePartPath)
791
- ? parseFontTable(
792
- decodeUtf8(sourcePackage.parts.get(fontTablePartPath)?.bytes ?? new Uint8Array()),
793
- )
794
- : undefined;
795
-
796
- const mergedMedia = mergeSecondaryStoryMediaCatalog(normalizedDocument.media, {
797
- headers: parsedHeaders,
798
- footers: parsedFooters,
799
- footnoteCollection,
800
- mediaParts,
801
- });
802
-
803
- const subParts: SubPartsCatalog | undefined =
804
- parsedHeaders.length > 0 ||
805
- parsedFooters.length > 0 ||
806
- footnoteCollection !== undefined ||
807
- parsedTheme !== undefined ||
808
- normalizedDocument.finalSectionProperties !== undefined ||
809
- resolvedTheme !== undefined ||
810
- canonicalTheme !== undefined ||
811
- parsedSettings !== undefined
812
- ? {
813
- headers: parsedHeaders,
814
- footers: parsedFooters,
815
- ...(footnoteCollection !== undefined ? { footnoteCollection } : {}),
816
- ...(parsedTheme !== undefined ? { theme: parsedTheme } : {}),
817
- ...(normalizedDocument.finalSectionProperties !== undefined
818
- ? { finalSectionProperties: normalizedDocument.finalSectionProperties }
819
- : {}),
820
- ...(resolvedTheme !== undefined ? { resolvedTheme } : {}),
821
- ...(canonicalTheme !== undefined ? { canonicalTheme } : {}),
822
- ...(parsedSettings !== undefined ? { settings: parsedSettings } : {}),
823
- }
824
- : undefined;
825
-
826
- const timestamp = new Date().toISOString();
827
- const translatedWorkflowState = translateClmCommentsToWorkflow({
828
- comments: normalizedComments.threads,
829
- workflowOverlay: embeddedWorkflowOverlay,
830
- workflowMetadata: embeddedWorkflowMetadata,
831
- timestamp,
832
- });
833
- const document = createImportedCanonicalDocument({
834
- documentId: options.documentId,
835
- timestamp,
836
- numbering: parsedNumbering,
837
- media: mergedMedia,
838
- content: normalizedDocument.content,
839
- subParts,
840
- parsedStyles,
841
- fontTable: parsedFontTable,
842
- preservation: {
843
- ...normalizedDocument.preservation,
844
- packageParts: {
845
- ...normalizedDocument.preservation.packageParts,
846
- ...collectPreservedPackageParts(sourcePackage, [
847
- mainDocumentPath,
848
- numberingPartPath,
849
- commentsPartPath,
850
- commentsExtendedPartPath,
851
- commentsIdsPartPath,
852
- peoplePartPath,
853
- ]),
854
- },
855
- },
856
- diagnostics: {
857
- warnings: [
858
- ...createBrokenRelationshipWarnings(sourcePackage, mainDocumentPath),
859
- ...normalizedDocument.diagnostics.warnings,
860
- ...normalizedRevisions.diagnostics.map((diagnostic, index) => ({
861
- diagnosticId: `diagnostic:revision-import-${index + 1}`,
862
- warningId: `warning:revision-import-${diagnostic.revisionId}`,
863
- source: "review" as const,
864
- message: diagnostic.message,
865
- })),
866
- ...importedStoryRevisionDiagnostics.map((diagnostic, index) => ({
867
- diagnosticId: `diagnostic:story-revision-import-${index + 1}`,
868
- warningId: `warning:story-revision-import-${diagnostic.revisionId}`,
869
- source: "review" as const,
870
- message: diagnostic.message,
871
- })),
872
- ...normalizedComments.diagnostics.map((diagnostic, index) => ({
873
- diagnosticId: `diagnostic:comment-import-${index + 1}`,
874
- warningId: `warning:comment-import-${diagnostic.commentId}`,
875
- source: "review" as const,
876
- message: diagnostic.message,
877
- })),
878
- ],
879
- errors: [],
880
- },
881
- review: {
882
- comments: toRuntimeCommentRecords(translatedWorkflowState.comments),
883
- revisions: toRuntimeRevisionRecords([
884
- ...normalizedRevisions.revisions,
885
- ...importedStoryRevisions,
886
- ]),
887
- },
888
- });
889
- const compatibility = buildCompatibilityReport({
890
- document,
891
- generatedAt: timestamp,
892
- });
893
- const snapshot = createImportedSnapshot({
894
- documentId: options.documentId,
895
- editorBuild,
896
- timestamp,
897
- document,
898
- compatibility: toPublicCompatibilityReport(compatibility),
899
- protectionSnapshot: importedProtectionSnapshot,
900
- sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
901
- workflowOverlay: translatedWorkflowState.workflowOverlay,
902
- workflowMetadata: translatedWorkflowState.workflowMetadata,
903
- });
904
- const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
905
- if (snapshotIssues.length > 0) {
906
- const firstIssue = snapshotIssues[0];
907
- return createDiagnosticsSession(
908
- options,
909
- createValidationImportDiagnostics({
910
- message: `DOCX import produced an invalid editor state during validation${firstIssue ? ` (${firstIssue.path}: ${firstIssue.message})` : "."}`,
911
- source: "import",
912
- details: {
913
- issueCount: snapshotIssues.length,
914
- firstIssuePath: firstIssue?.path,
915
- },
916
- }),
917
- );
918
- }
919
- const initialSessionState = editorSessionStateFromPersistedSnapshot(snapshot);
920
- const importedState: ImportedDocxState = {
921
- sourceBytes: new Uint8Array(sourceBytes),
922
- sourcePackage,
923
- sourceDocumentXml,
924
- sourceDocumentPartPath: mainDocumentPath,
925
- sourceDocumentRelationships: documentPart.relationships,
926
- sourceDocumentAttributes: extractDocumentRootAttributes(sourceDocumentXml),
927
- sourceNumberingPartPath: numberingPartPath,
928
- sourceNumberingRelationshipId: documentPart.relationships.find(
929
- (relationship) =>
930
- relationship.type === NUMBERING_RELATIONSHIP_TYPE &&
931
- relationship.targetMode === "internal",
932
- )?.id,
933
- sourceSettingsPartPath: settingsPartPath,
934
- sourceSettingsXml:
935
- settingsXmlForProtection.length > 0 ? settingsXmlForProtection : undefined,
936
- sourceCommentsPartPath: commentsPartPath,
937
- sourceCommentsRelationshipId: documentPart.relationships.find(
938
- (relationship) =>
939
- relationship.type === COMMENTS_RELATIONSHIP_TYPE &&
940
- relationship.targetMode === "internal",
941
- )?.id,
942
- sourceCommentsRootTag: normalizedComments.sourceRootTag,
943
- sourceCommentsExtendedPartPath: commentsExtendedPartPath,
944
- sourceCommentsExtendedRelationshipId: documentPart.relationships.find(
945
- (relationship) =>
946
- relationship.type === COMMENTS_EXTENDED_RELATIONSHIP_TYPE &&
947
- relationship.targetMode === "internal",
948
- )?.id,
949
- sourceCommentsExtendedRootTag: normalizedComments.sourceExtendedRootTag,
950
- sourceCommentsIdsPartPath: commentsIdsPartPath,
951
- sourceCommentsIdsRelationshipId: documentPart.relationships.find(
952
- (relationship) =>
953
- relationship.type === COMMENTS_IDS_RELATIONSHIP_TYPE &&
954
- relationship.targetMode === "internal",
955
- )?.id,
956
- sourceCommentsIdsRootTag: normalizedComments.sourceIdsRootTag,
957
- sourcePeoplePartPath: peoplePartPath,
958
- sourcePeopleRelationshipId: documentPart.relationships.find(
959
- (relationship) =>
960
- relationship.type === PEOPLE_RELATIONSHIP_TYPE &&
961
- relationship.targetMode === "internal",
962
- )?.id,
963
- sourcePeopleRootTag: normalizedComments.sourcePeopleRootTag,
964
- sourcePeopleAuthors: normalizedComments.peopleAuthors,
965
- protectionSnapshot: buildProtectionSnapshot(documentProtection, protectionRanges),
966
- preservedCommentDefinitions: normalizedComments.preservedDefinitions,
967
- blockingCommentDiagnostics: normalizedComments.diagnostics.filter((diagnostic) =>
968
- BLOCKING_COMMENT_DIAGNOSTIC_CODES.has(diagnostic.code),
969
- ),
970
- initialCanonicalSignature: serializeCanonicalDocumentForExport(document),
971
- sourceSubPartPaths: {
972
- headers: sourceHeaderPaths,
973
- footers: sourceFooterPaths,
974
- footnotesPartPath,
975
- footnotesRelationshipId,
976
- endnotesPartPath,
977
- endnotesRelationshipId,
978
- themePartPath,
979
- themeRelationshipId: themeRelationship?.id,
980
- },
981
- };
982
-
983
- stages.emit("skeleton-ready");
984
- return {
985
- initialSessionState,
986
- initialSnapshot: snapshot,
987
- readOnly: false,
988
- protectionSnapshot: importedProtectionSnapshot,
989
- exportDocx: async (nextSessionState, exportOptions) =>
990
- exportDocxEditorSession(importedState, nextSessionState, exportOptions),
991
- ...(embeddedWorkflowPayload?.editorState
992
- ? { initialEditorStatePayload: embeddedWorkflowPayload.editorState }
993
- : {}),
994
- };
995
- } catch (error) {
996
- return createDiagnosticsSession(
997
- options,
998
- createImportDiagnosticsFromError(error),
999
- );
1000
- }
1001
- }
1002
-
1003
- export interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSessionOptions {
1004
- /**
1005
- * Scheduler that the async loader awaits between parse stages. Callers
1006
- * in DOM environments should construct this with `createLoadScheduler()`
1007
- * (auto-detects scheduler.yield → MessageChannel → setTimeout backend).
1008
- * Callers in Node / SSR should pass
1009
- * `createLoadScheduler({ backendOverride: "sync" })` — sync backend
1010
- * yields resolve without task-boundary latency, so the async path
1011
- * behaves like the sync path from the test harness POV.
1012
- */
1013
- scheduler: LoadScheduler;
1014
- /**
1015
- * L7 Phase 2.5 Plan B B.6b — optional laycache envelope. When supplied,
1016
- * `loadDocxEditorSessionAsync` still performs the cheap OPC read +
1017
- * workflow-payload parse (needed for `initialEditorStatePayload`,
1018
- * `workflowOverlay`, and `workflowMetadata`), then skips the five
1019
- * expensive stages — `parseMainDocumentXml`,
1020
- * `normalizeParsedTextDocumentAsync`, `parseCommentsFromOoxml`,
1021
- * `parseStylesXml`, and `createImportedCanonicalDocument` — and uses
1022
- * `envelope.canonicalDocument` directly (reference-equal, no clone).
1023
- *
1024
- * Callers obtain a validated envelope by calling
1025
- * `tryReadLaycacheEnvelope(bytes)` before invoking this function; when
1026
- * the probe returns `null`, omit this field and the loader runs the
1027
- * full parse.
1028
- *
1029
- * Async-only — the sync `loadDocxEditorSession` does not honor this
1030
- * option. `buildCompatibilityReport` and
1031
- * `resolveChartPreviewsForDocument` still run on the short-circuit
1032
- * path because their outputs are required by downstream consumers.
1033
- */
1034
- laycacheEnvelope?: CacheEnvelope;
1035
- /**
1036
- * L7 Phase 2 Finale C2 — progressive initial mount. When supplied, the
1037
- * async loader fires this callback exactly once, after the body stage
1038
- * completes but before styles / sub-parts / compatibility / snapshot
1039
- * assembly, with a viewport-windowed `EditorSurfaceSnapshot`. The
1040
- * callback lets UI consumers show the first page's text before the
1041
- * rest of the load finishes, measurably shrinking perceived cold-open.
1042
- *
1043
- * The progressive surface is synthesized from a provisional
1044
- * `CanonicalDocumentEnvelope`: `content` is the normalized body; all
1045
- * other catalogs (styles, numbering, media, review, preservation) are
1046
- * empty. Viewport blocks render live; blocks beyond the viewport
1047
- * render as placeholders via the existing `cullBuild` flag. Consumers
1048
- * must treat the progressive surface as provisional — the final
1049
- * `LoadedDocxEditorSession` (returned from the same `await`) carries
1050
- * the real styled envelope.
1051
- *
1052
- * The callback receives `blocksRealized` (the viewport window size)
1053
- * and `blocksTotal` (total block count at body-stage time) so the
1054
- * consumer can size its viewport-commit telemetry.
1055
- *
1056
- * Optional. When absent, the async loader does not perform the
1057
- * provisional-envelope synthesis — no cold-path regression for
1058
- * consumers that do not opt in.
1059
- *
1060
- * Omitted on the Plan B short-circuit path (laycacheEnvelope !== undefined):
1061
- * the short-circuit is already fast enough that a progressive pre-commit
1062
- * adds more overhead than it saves.
1063
- */
1064
- onProgressiveSnapshot?: (partial: {
1065
- surface: EditorSurfaceSnapshot;
1066
- phase: "viewport";
1067
- blocksRealized: number;
1068
- blocksTotal: number;
1069
- }) => void;
1070
- /**
1071
- * C3b — deferred chart preview resolution. When supplied, chart preview
1072
- * rendering (`renderChartPreview()` calls) is removed from the critical
1073
- * load path: the session returns without chart previews, and this
1074
- * callback fires later (via requestIdleCallback / setTimeout) with the
1075
- * CanonicalDocument that has all chart previews resolved. Expected win:
1076
- * 30–80 ms on extra-large warm-path docs with ~12 charts.
1077
- *
1078
- * Consumers that need chart previews on first render should not supply
1079
- * this callback — the blocking path remains available by omitting it.
1080
- */
1081
- onChartPreviewsReady?: (resolvedDoc: CanonicalDocument) => void;
1082
- }
1083
-
1084
- /**
1085
- * Fastload P6: async sibling of {@link loadDocxEditorSession} that yields to
1086
- * the browser between parse stages. Parse sequence is byte-equivalent to
1087
- * the sync path (asserted on every F*.docx fixture in
1088
- * `test/io/fastload-parity.test.ts`). Yields fire at:
1089
- * 1. after OPC read,
1090
- * 2. after `parseMainDocumentXml`,
1091
- * 3. between every header/footer sub-part parse,
1092
- * 4. after footnotes/endnotes parse,
1093
- * 5. after theme + settings + styles parse,
1094
- * 6. after `buildCompatibilityReport`,
1095
- * plus mid-walk yields every 256 blocks inside
1096
- * `normalizeParsedTextDocumentAsync` (stage 3 — body normalize).
1097
- *
1098
- * Sync `loadDocxEditorSession` remains the only entry point for Node tests
1099
- * and SSR. The DOM boundary in `editor-runtime-boundary.ts` calls this
1100
- * async path so the browser can paint the skeleton mid-parse.
1101
- */
1102
-
1103
- /**
1104
- * L7 Phase 2 Finale C2 — progressive initial mount viewport window size.
1105
- *
1106
- * Sized to cover the first page of a typical paginated document (~20
1107
- * body blocks = ~1 page on CCEP-scale templates at default margins). The
1108
- * window is intentionally small so the provisional-envelope synthesis +
1109
- * surface projection stays under ~30 ms — making `firstViewportCommit`
1110
- * significantly faster than the full load.
1111
- *
1112
- * Blocks beyond this window render as `placeholder-culled` entries via
1113
- * Phase 2.9's cullBuild flag (auto-derived from `!isInViewport`) — they
1114
- * preserve `from`/`to` offsets for selection stability while costing ~0
1115
- * style-cascade work.
1116
- */
1117
- const PROGRESSIVE_VIEWPORT_BLOCKS = 20;
1118
-
1119
- export async function loadDocxEditorSessionAsync(
1120
- options: LoadDocxEditorSessionAsyncOptions,
1121
- ): Promise<LoadedDocxEditorSession> {
1122
- const { scheduler } = options;
1123
- const editorBuild =
1124
- typeof options.editorBuild === "string" && options.editorBuild.length > 0
1125
- ? options.editorBuild
1126
- : "dev";
1127
- const sourceBytes = toUint8Array(options.bytes);
1128
-
1129
- const stages = createStageEmitter(options.onLoadStage);
1130
-
1131
- let sourcePackage: OpcPackage;
1132
-
1133
- try {
1134
- sourcePackage = readOpcPackage(sourceBytes);
1135
- } catch (error) {
1136
- return createDiagnosticsSession(
1137
- options,
1138
- createPackageImportDiagnostics({
1139
- issue: classifyCorruptPackageError(error),
1140
- }),
1141
- );
1142
- }
1143
- stages.emit("opc");
1144
- await scheduler.yield();
1145
- const embeddedWorkflowPayload = parseWorkflowPayloadEnvelopeFromPackage(sourcePackage);
1146
- const embeddedWorkflowMetadata = embeddedWorkflowPayload?.workflowMetadata;
1147
- const embeddedWorkflowOverlay = embeddedWorkflowPayload?.workflowOverlay;
1148
-
1149
- const mainDocumentPath = resolveMainDocumentPartPath(sourcePackage);
1150
- const brokenRelationshipIssues = collectBrokenInternalRelationshipIssues(
1151
- sourcePackage,
1152
- mainDocumentPath,
1153
- );
1154
- if (brokenRelationshipIssues.length > 0) {
1155
- return createDiagnosticsSession(
1156
- options,
1157
- createPackageImportDiagnostics({
1158
- issue: {
1159
- ...brokenRelationshipIssues[0],
1160
- message: summarizeBrokenRelationshipIssues(brokenRelationshipIssues),
1161
- details: {
1162
- issueCount: brokenRelationshipIssues.length,
1163
- targets: brokenRelationshipIssues.map((issue) => issue.targetPartPath).filter(Boolean),
1164
- },
1165
- },
1166
- }),
1167
- );
1168
- }
1169
-
1170
- if (!mainDocumentPath) {
1171
- return createDiagnosticsSession(
1172
- options,
1173
- createPackageImportDiagnostics({
1174
- issue: createMissingPartIssue(MAIN_DOCUMENT_PATH),
1175
- }),
1176
- );
1177
- }
1178
-
1179
- const documentPart = sourcePackage.parts.get(mainDocumentPath);
1180
- if (!documentPart) {
1181
- return createDiagnosticsSession(
1182
- options,
1183
- createPackageImportDiagnostics({
1184
- issue: createMissingPartIssue(mainDocumentPath),
1185
- }),
1186
- );
1187
- }
1188
- if (documentPart.contentType !== MAIN_DOCUMENT_CONTENT_TYPE) {
1189
- return createDiagnosticsSession(
1190
- options,
1191
- createValidationImportDiagnostics({
1192
- message: `DOCX main document part ${mainDocumentPath} must use content type ${MAIN_DOCUMENT_CONTENT_TYPE}.`,
1193
- }),
1194
- );
1195
- }
1196
-
1197
- try {
1198
- // L7 Phase 2.5 Plan B B.6b — loader short-circuit. Hand
1199
- // `envelope.canonicalDocument` through reference-equal and skip the
1200
- // five expensive parse stages. The four `onLoadStage` callbacks still
1201
- // fire in order — `body` and `styles-numbering-comments` emit with
1202
- // near-zero duration — so host progress bars are unaffected.
1203
- if (options.laycacheEnvelope) {
1204
- stages.emit("body");
1205
- stages.emit("styles-numbering-comments");
1206
-
1207
- const canonicalDocument = options.laycacheEnvelope.canonicalDocument;
1208
-
1209
- // `extractProtectionRanges` needs `parsedDocument.blocks` (which we
1210
- // are skipping), so the short-circuit uses an empty ranges list;
1211
- // document-level `editType` / `enforcement` still come from
1212
- // settings.xml so read-only docs stay read-only.
1213
- const settingsPartPath = resolveDocumentRelatedPartPath(
1214
- sourcePackage,
1215
- mainDocumentPath,
1216
- documentPart.relationships,
1217
- SETTINGS_RELATIONSHIP_TYPE,
1218
- SETTINGS_PART_PATH,
1219
- );
1220
- const settingsXml =
1221
- settingsPartPath && sourcePackage.parts.has(settingsPartPath)
1222
- ? decodeUtf8(
1223
- sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array(),
1224
- )
1225
- : "";
1226
- const documentProtection = extractDocumentProtection(settingsXml);
1227
- const protectionSnapshot = buildProtectionSnapshot(documentProtection, []);
1228
-
1229
- // Chart previews (`previewMediaId` is host-dependent) aren't cached
1230
- // in the envelope. C3b: when onChartPreviewsReady is provided, defer
1231
- // resolution out of the critical path. Otherwise block (legacy behavior).
1232
- let documentWithChartPreviews: CanonicalDocumentEnvelope;
1233
- if (options.onChartPreviewsReady) {
1234
- scheduleChartPreviewResolution(
1235
- canonicalDocument,
1236
- sourcePackage,
1237
- options.hostAdapter,
1238
- options.onChartPreviewsReady,
1239
- );
1240
- documentWithChartPreviews = canonicalDocument as CanonicalDocumentEnvelope;
1241
- } else {
1242
- documentWithChartPreviews = (await resolveChartPreviewsForDocument(
1243
- canonicalDocument,
1244
- sourcePackage,
1245
- options.hostAdapter,
1246
- )) as CanonicalDocumentEnvelope;
1247
- }
1248
-
1249
- const timestamp = new Date().toISOString();
1250
- // Phase 2 Finale C3: skip `buildCompatibilityReport` (60–100 ms on
1251
- // extra-large) when the envelope carries a pre-computed report.
1252
- // Pure-function determinism of the report is enforced by
1253
- // `canonicalDocumentHash` (5th input to `deriveCacheKey`): any
1254
- // change to the canonical doc flips the hash and rejects the
1255
- // envelope on load.
1256
- //
1257
- // The cached report's `generatedAt` is a fixed sentinel
1258
- // (`CACHE_NORMALIZED_GENERATED_AT`) for envelope byte-identity.
1259
- // Swap it for the live ISO8601 timestamp here because downstream
1260
- // `validatePersistedEditorSnapshot` requires
1261
- // `$.compatibility.generatedAt` to be ISO 8601.
1262
- const cachedReport = options.laycacheEnvelope?.compatibilityReport;
1263
- const compatibility = cachedReport
1264
- ? { ...cachedReport, generatedAt: timestamp }
1265
- : buildCompatibilityReport({
1266
- document: documentWithChartPreviews,
1267
- generatedAt: timestamp,
1268
- });
1269
- await scheduler.yield();
1270
-
1271
- const snapshot = createImportedSnapshot({
1272
- documentId: options.documentId,
1273
- editorBuild,
1274
- timestamp,
1275
- document: documentWithChartPreviews,
1276
- compatibility: toPublicCompatibilityReport(compatibility),
1277
- protectionSnapshot,
1278
- sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
1279
- workflowOverlay: embeddedWorkflowOverlay,
1280
- workflowMetadata: embeddedWorkflowMetadata,
1281
- });
1282
- const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
1283
- if (snapshotIssues.length > 0) {
1284
- const firstIssue = snapshotIssues[0];
1285
- return createDiagnosticsSession(
1286
- options,
1287
- createValidationImportDiagnostics({
1288
- message: `DOCX import produced an invalid editor state during validation${firstIssue ? ` (${firstIssue.path}: ${firstIssue.message})` : "."}`,
1289
- source: "import",
1290
- details: {
1291
- issueCount: snapshotIssues.length,
1292
- firstIssuePath: firstIssue?.path,
1293
- },
1294
- }),
1295
- );
1296
- }
1297
-
1298
- // Build `initialSessionState` inline — bypassing
1299
- // `editorSessionStateFromPersistedSnapshot`'s structuredClone so
1300
- // `session.initialSessionState.canonicalDocument` is reference-equal
1301
- // to `envelope.canonicalDocument` (cloning a large canonical document
1302
- // defeats part of the cache gain).
1303
- const sessionState: EditorSessionState = {
1304
- sessionVersion: "editor-session-state/1",
1305
- schemaVersion: snapshot.schemaVersion,
1306
- documentId: snapshot.documentId,
1307
- docId: snapshot.docId,
1308
- createdAt: snapshot.createdAt,
1309
- updatedAt: snapshot.updatedAt,
1310
- editorBuild: snapshot.editorBuild,
1311
- canonicalDocument: snapshot.canonicalDocument,
1312
- compatibility: snapshot.compatibility,
1313
- warningLog: snapshot.warningLog,
1314
- protectionSnapshot: snapshot.protectionSnapshot,
1315
- sourcePackage: snapshot.sourcePackage,
1316
- workflowOverlay: snapshot.workflowOverlay,
1317
- workflowMetadata: snapshot.workflowMetadata,
1318
- };
1319
-
1320
- // The short-circuit path does not carry an `ImportedDocxState`, so
1321
- // `exportDocx` lazily re-runs the cold path on first invocation and
1322
- // memoizes. Keeps the warm-load fast while preserving byte-exact
1323
- // export correctness.
1324
- let lazyColdExport: LoadedDocxEditorSession["exportDocx"] | undefined;
1325
- const exportDocx: LoadedDocxEditorSession["exportDocx"] = async (
1326
- nextSessionStateOrSnapshot,
1327
- exportOptions,
1328
- ) => {
1329
- if (!lazyColdExport) {
1330
- const { laycacheEnvelope: _unused, ...coldOptions } = options;
1331
- void _unused;
1332
- const coldSession = await loadDocxEditorSessionAsync(coldOptions);
1333
- if (coldSession.fatalError) {
1334
- throw new Error(
1335
- `DOCX export via short-circuit fallback failed cold load: ${coldSession.fatalError.message ?? "fatal error"}`,
1336
- );
1337
- }
1338
- lazyColdExport = coldSession.exportDocx;
1339
- }
1340
- return lazyColdExport(nextSessionStateOrSnapshot, exportOptions);
1341
- };
1342
-
1343
- stages.emit("skeleton-ready");
1344
- return {
1345
- initialSessionState: sessionState,
1346
- initialSnapshot: snapshot,
1347
- readOnly: false,
1348
- protectionSnapshot,
1349
- exportDocx,
1350
- ...(embeddedWorkflowPayload?.editorState
1351
- ? { initialEditorStatePayload: embeddedWorkflowPayload.editorState }
1352
- : {}),
1353
- };
1354
- }
1355
-
1356
- const sourceDocumentXml = decodeUtf8(documentPart.bytes);
1357
- const importedRevisions = parseRevisionsFromDocumentXml(sourceDocumentXml);
1358
- const numberingPartPath = resolveDocumentRelatedPartPath(
1359
- sourcePackage,
1360
- mainDocumentPath,
1361
- documentPart.relationships,
1362
- NUMBERING_RELATIONSHIP_TYPE,
1363
- NUMBERING_PART_PATH,
1364
- );
1365
- const parsedNumbering = numberingPartPath
1366
- ? parseNumberingXml(
1367
- decodeUtf8(sourcePackage.parts.get(numberingPartPath)?.bytes ?? new Uint8Array()),
1368
- {
1369
- relationships: sourcePackage.parts.get(numberingPartPath)?.relationships,
1370
- partPath: numberingPartPath,
1371
- },
1372
- )
1373
- : createEmptyNumberingCatalog();
1374
- const mediaParts = collectInlineMediaParts(sourcePackage);
1375
- const chartPartLookup = createChartPartLookup(
1376
- sourcePackage,
1377
- mainDocumentPath,
1378
- documentPart.relationships,
1379
- );
1380
- const parsedDocument = parseMainDocumentXml(
1381
- sourceDocumentXml,
1382
- documentPart.relationships,
1383
- mediaParts,
1384
- mainDocumentPath,
1385
- chartPartLookup,
1386
- );
1387
- await scheduler.yield();
1388
- const protectionRanges = extractProtectionRanges(parsedDocument.blocks);
1389
- const normalizedDocument = await normalizeParsedTextDocumentAsync(
1390
- parsedDocument,
1391
- mainDocumentPath,
1392
- scheduler,
1393
- );
1394
- stages.emit("body");
1395
- await scheduler.yield();
1396
-
1397
- // L7 Phase 2 Finale C2 — progressive initial mount.
1398
- //
1399
- // Fire `onProgressiveSnapshot` exactly once, after the body stage and
1400
- // its post-yield. At this point `normalizedDocument.content` carries
1401
- // the full block tree with per-block runProperties already resolved
1402
- // during `normalizeParsedTextDocumentAsync`. We synthesize a
1403
- // throw-away `CanonicalDocumentEnvelope` using the normalized content
1404
- // + empty style/review/preservation catalogs, then project a
1405
- // viewport-windowed `EditorSurfaceSnapshot` (first `PROGRESSIVE_VIEWPORT_BLOCKS`
1406
- // blocks real, rest as culled placeholders via the Phase 2.9 flag).
1407
- //
1408
- // The bench's measured signal: time from `loadDocxEditorSessionAsync`
1409
- // entry to this callback's fire is `firstViewportCommitMs` — the
1410
- // metric C2 gates on.
1411
- //
1412
- // Skipped on the Plan B short-circuit: `laycacheEnvelope !== undefined`
1413
- // already completes ~376 ms faster than cold — adding a progressive
1414
- // synthesis on top costs more than it saves. The short-circuit path
1415
- // returns the real snapshot fast enough.
1416
- if (
1417
- options.onProgressiveSnapshot !== undefined &&
1418
- options.laycacheEnvelope === undefined
1419
- ) {
1420
- const provisionalDoc: CanonicalDocumentEnvelope = {
1421
- ...createDefaultCanonicalDocument(
1422
- options.documentId,
1423
- new Date().toISOString(),
1424
- ),
1425
- content: normalizedDocument.content,
1426
- };
1427
- const blocksTotal = normalizedDocument.content.children.length;
1428
- const blocksRealized = Math.min(
1429
- PROGRESSIVE_VIEWPORT_BLOCKS,
1430
- blocksTotal,
1431
- );
1432
- const progressiveSurface = createEditorSurfaceSnapshot(
1433
- provisionalDoc,
1434
- createSelectionSnapshot(0, 0),
1435
- MAIN_STORY_TARGET,
1436
- blocksRealized < blocksTotal
1437
- ? { viewportBlockRange: { start: 0, end: blocksRealized } }
1438
- : undefined,
1439
- );
1440
- try {
1441
- options.onProgressiveSnapshot({
1442
- surface: progressiveSurface,
1443
- phase: "viewport",
1444
- blocksRealized,
1445
- blocksTotal,
1446
- });
1447
- } catch {
1448
- // A throwing consumer must not abort the load. Progressive is
1449
- // a best-effort optimization; errors on the callback side
1450
- // silently fall through to the normal full-commit path.
1451
- }
1452
- }
1453
-
1454
- const commentsPartPath = resolveCommentsPartPath(
1455
- sourcePackage,
1456
- mainDocumentPath,
1457
- documentPart.relationships,
1458
- );
1459
- const commentsExtendedPartPath = resolveDocumentRelatedPartPath(
1460
- sourcePackage,
1461
- mainDocumentPath,
1462
- documentPart.relationships,
1463
- COMMENTS_EXTENDED_RELATIONSHIP_TYPE,
1464
- COMMENTS_EXTENDED_PART_PATH,
1465
- );
1466
- const commentsIdsPartPath = resolveDocumentRelatedPartPath(
1467
- sourcePackage,
1468
- mainDocumentPath,
1469
- documentPart.relationships,
1470
- COMMENTS_IDS_RELATIONSHIP_TYPE,
1471
- COMMENTS_IDS_PART_PATH,
1472
- );
1473
- const peoplePartPath = resolveDocumentRelatedPartPath(
1474
- sourcePackage,
1475
- mainDocumentPath,
1476
- documentPart.relationships,
1477
- PEOPLE_RELATIONSHIP_TYPE,
1478
- PEOPLE_PART_PATH,
1479
- );
1480
- const parsedComments = commentsPartPath
1481
- ? parseCommentsFromOoxml(
1482
- sourceDocumentXml,
1483
- {
1484
- commentsXml: decodeUtf8(sourcePackage.parts.get(commentsPartPath)?.bytes ?? new Uint8Array()),
1485
- commentsExtendedXml: decodeUtf8(
1486
- sourcePackage.parts.get(commentsExtendedPartPath ?? "")?.bytes ?? new Uint8Array(),
1487
- ),
1488
- commentsIdsXml: decodeUtf8(
1489
- sourcePackage.parts.get(commentsIdsPartPath ?? "")?.bytes ?? new Uint8Array(),
1490
- ),
1491
- peopleXml: decodeUtf8(
1492
- sourcePackage.parts.get(peoplePartPath ?? "")?.bytes ?? new Uint8Array(),
1493
- ),
1494
- },
1495
- )
1496
- : {
1497
- threads: [] as CommentThread[],
1498
- diagnostics: [] as CommentImportDiagnostic[],
1499
- definitions: [] as ImportedCommentDefinition[],
1500
- sourceRootTag: undefined,
1501
- sourceExtendedRootTag: undefined,
1502
- sourceIdsRootTag: undefined,
1503
- sourcePeopleRootTag: undefined,
1504
- peopleAuthors: [] as string[],
1505
- };
1506
- const normalizedRevisions = normalizeImportedRevisionRecords(
1507
- importedRevisions,
1508
- normalizedDocument.content,
1509
- normalizedDocument.preservation.opaqueFragments,
1510
- );
1511
- const normalizedComments = normalizeImportedCommentThreads(
1512
- parsedComments,
1513
- normalizedDocument.preservation.opaqueFragments,
1514
- normalizedRevisions.revisions,
1515
- );
1516
- stages.emit("styles-numbering-comments");
1517
- const importedStoryRevisions: ReviewRevisionRecord[] = [];
1518
- const importedStoryRevisionDiagnostics: ParsedRevisionsResult["diagnostics"] = [];
1519
- const subPartOpaqueState = createSubPartOpaqueImportState(
1520
- normalizedDocument.preservation.opaqueFragments,
1521
- normalizedDocument.diagnostics.warnings,
1522
- );
1523
- // ---- Parse sub-parts: headers, footers, footnotes, endnotes, theme ----
1524
- const headerFooterRefs = parseHeaderFooterReferences(sourceDocumentXml);
1525
- const parsedHeaders: HeaderDocument[] = [];
1526
- const parsedFooters: FooterDocument[] = [];
1527
- const sourceHeaderPaths: Array<{ partPath: string; relationshipId: string }> = [];
1528
- const sourceFooterPaths: Array<{ partPath: string; relationshipId: string }> = [];
1529
- const seenSubPartKeys = new Set<string>();
1530
-
1531
- for (const ref of headerFooterRefs) {
1532
- const dedupeKey = `${ref.kind}:${ref.variant}:${ref.relationshipId}`;
1533
- if (seenSubPartKeys.has(dedupeKey)) {
1534
- continue;
1535
- }
1536
- seenSubPartKeys.add(dedupeKey);
1537
-
1538
- const relationship = documentPart.relationships.find(
1539
- (r) => r.id === ref.relationshipId && r.targetMode === "internal",
1540
- );
1541
- if (!relationship) {
1542
- continue;
1543
- }
1544
-
1545
- const partPath = resolveRelationshipTarget(mainDocumentPath, relationship);
1546
- const part = sourcePackage.parts.get(partPath);
1547
- const partBytes = part?.bytes;
1548
- if (!partBytes) {
1549
- continue;
1550
- }
1551
-
1552
- await scheduler.yield();
1553
- const xml = decodeUtf8(partBytes);
1554
- const subPartRelationships = part?.relationships ?? [];
1555
- const subPartChartPartLookup = createChartPartLookup(
1556
- sourcePackage,
1557
- partPath,
1558
- subPartRelationships,
1559
- );
1560
- if (ref.kind === "header") {
1561
- const parsedHeaderRevisions = parseRevisionsFromStoryXml(xml);
1562
- const parsed = parseHeaderXml(xml, {
1563
- relationships: subPartRelationships,
1564
- mediaParts,
1565
- sourcePartPath: partPath,
1566
- chartPartLookup: subPartChartPartLookup,
1567
- });
1568
- parsedHeaders.push({
1569
- variant: ref.variant,
1570
- partPath,
1571
- relationshipId: ref.relationshipId,
1572
- ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
1573
- blocks: normalizeSubPartOpaqueBlocks(
1574
- parsed.blocks,
1575
- normalizedDocument.preservation.opaqueFragments,
1576
- normalizedDocument.diagnostics.warnings,
1577
- partPath,
1578
- subPartOpaqueState,
1579
- ),
1580
- });
1581
- importedStoryRevisions.push(
1582
- ...parsedHeaderRevisions.revisions.map((revision): ReviewRevisionRecord => ({
1583
- ...revision,
1584
- metadata: {
1585
- ...revision.metadata,
1586
- storyTarget: {
1587
- kind: "header" as const,
1588
- relationshipId: ref.relationshipId,
1589
- variant: ref.variant,
1590
- ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
1591
- },
1592
- },
1593
- })),
1594
- );
1595
- importedStoryRevisionDiagnostics.push(...parsedHeaderRevisions.diagnostics);
1596
- sourceHeaderPaths.push({ partPath, relationshipId: ref.relationshipId });
1597
- } else {
1598
- const parsedFooterRevisions = parseRevisionsFromStoryXml(xml);
1599
- const parsed = parseFooterXml(xml, {
1600
- relationships: subPartRelationships,
1601
- mediaParts,
1602
- sourcePartPath: partPath,
1603
- chartPartLookup: subPartChartPartLookup,
1604
- });
1605
- parsedFooters.push({
1606
- variant: ref.variant,
1607
- partPath,
1608
- relationshipId: ref.relationshipId,
1609
- ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
1610
- blocks: normalizeSubPartOpaqueBlocks(
1611
- parsed.blocks,
1612
- normalizedDocument.preservation.opaqueFragments,
1613
- normalizedDocument.diagnostics.warnings,
1614
- partPath,
1615
- subPartOpaqueState,
1616
- ),
1617
- });
1618
- importedStoryRevisions.push(
1619
- ...parsedFooterRevisions.revisions.map((revision): ReviewRevisionRecord => ({
1620
- ...revision,
1621
- metadata: {
1622
- ...revision.metadata,
1623
- storyTarget: {
1624
- kind: "footer" as const,
1625
- relationshipId: ref.relationshipId,
1626
- variant: ref.variant,
1627
- ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
1628
- },
1629
- },
1630
- })),
1631
- );
1632
- importedStoryRevisionDiagnostics.push(...parsedFooterRevisions.diagnostics);
1633
- sourceFooterPaths.push({ partPath, relationshipId: ref.relationshipId });
1634
- }
1635
- }
1636
-
1637
- const footnotesPartPath = resolveDocumentRelatedPartPath(
1638
- sourcePackage,
1639
- mainDocumentPath,
1640
- documentPart.relationships,
1641
- FOOTNOTES_RELATIONSHIP_TYPE,
1642
- FOOTNOTES_PART_PATH,
1643
- );
1644
- const footnotesRelationshipId = documentPart.relationships.find(
1645
- (r) => r.type === FOOTNOTES_RELATIONSHIP_TYPE && r.targetMode === "internal",
1646
- )?.id;
1647
- const endnotesPartPath = resolveDocumentRelatedPartPath(
1648
- sourcePackage,
1649
- mainDocumentPath,
1650
- documentPart.relationships,
1651
- ENDNOTES_RELATIONSHIP_TYPE,
1652
- ENDNOTES_PART_PATH,
1653
- );
1654
- const endnotesRelationshipId = documentPart.relationships.find(
1655
- (r) => r.type === ENDNOTES_RELATIONSHIP_TYPE && r.targetMode === "internal",
1656
- )?.id;
1657
-
1658
- let footnoteCollection: FootnoteCollection | undefined;
1659
- if (footnotesPartPath) {
1660
- footnoteCollection = parseFootnotesXml(
1661
- decodeUtf8(sourcePackage.parts.get(footnotesPartPath)?.bytes ?? new Uint8Array()),
1662
- );
1663
- normalizeFootnoteCollectionOpaqueBlocks(
1664
- footnoteCollection,
1665
- "footnote",
1666
- normalizedDocument.preservation.opaqueFragments,
1667
- normalizedDocument.diagnostics.warnings,
1668
- footnotesPartPath,
1669
- subPartOpaqueState,
1670
- );
1671
- }
1672
- if (endnotesPartPath) {
1673
- footnoteCollection = parseEndnotesXml(
1674
- decodeUtf8(sourcePackage.parts.get(endnotesPartPath)?.bytes ?? new Uint8Array()),
1675
- footnoteCollection,
1676
- );
1677
- normalizeFootnoteCollectionOpaqueBlocks(
1678
- footnoteCollection,
1679
- "endnote",
1680
- normalizedDocument.preservation.opaqueFragments,
1681
- normalizedDocument.diagnostics.warnings,
1682
- endnotesPartPath,
1683
- subPartOpaqueState,
1684
- );
1685
- }
1686
- await scheduler.yield();
1687
-
1688
- const themeRelationship = documentPart.relationships.find(
1689
- (r) => r.type === "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" &&
1690
- r.targetMode === "internal",
1691
- );
1692
- const themePartPath = themeRelationship
1693
- ? resolveRelationshipTarget(mainDocumentPath, themeRelationship)
1694
- : undefined;
1695
- const parsedTheme =
1696
- themePartPath && sourcePackage.parts.has(themePartPath)
1697
- ? parseThemeXml(
1698
- decodeUtf8(sourcePackage.parts.get(themePartPath)?.bytes ?? new Uint8Array()),
1699
- )
1700
- : undefined;
1701
- const resolvedTheme = parsedTheme ? resolveTheme(parsedTheme) : undefined;
1702
- const settingsPartPath = resolveDocumentRelatedPartPath(
1703
- sourcePackage,
1704
- mainDocumentPath,
1705
- documentPart.relationships,
1706
- SETTINGS_RELATIONSHIP_TYPE,
1707
- SETTINGS_PART_PATH,
1708
- );
1709
- const parsedSettings =
1710
- settingsPartPath && sourcePackage.parts.has(settingsPartPath)
1711
- ? parseSettingsXml(
1712
- decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array()),
1713
- )
1714
- : undefined;
1715
- const canonicalTheme =
1716
- parsedTheme !== undefined
1717
- ? materializeCanonicalTheme(
1718
- parsedTheme,
1719
- parsedSettings?.clrSchemeMapping ?? {},
1720
- )
1721
- : undefined;
1722
- const settingsXmlForProtection =
1723
- settingsPartPath && sourcePackage.parts.has(settingsPartPath)
1724
- ? decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array())
1725
- : "";
1726
- const documentProtection = extractDocumentProtection(settingsXmlForProtection);
1727
- const importedProtectionSnapshot = buildProtectionSnapshot(documentProtection, protectionRanges);
1728
-
1729
- // ---- Parse styles.xml for canonical style catalog ----
1730
- const stylesPartPath = resolveDocumentRelatedPartPath(
1731
- sourcePackage,
1732
- mainDocumentPath,
1733
- documentPart.relationships,
1734
- STYLES_RELATIONSHIP_TYPE,
1735
- STYLES_PART_PATH,
1736
- );
1737
- const parsedStyles =
1738
- stylesPartPath && sourcePackage.parts.has(stylesPartPath)
1739
- ? parseStylesXml(
1740
- decodeUtf8(sourcePackage.parts.get(stylesPartPath)?.bytes ?? new Uint8Array()),
1741
- )
1742
- : parseStylesXml("");
1743
- await scheduler.yield();
1744
-
1745
- // ---- Parse fontTable.xml for canonical font catalog ----
1746
- const fontTablePartPath = resolveDocumentRelatedPartPath(
1747
- sourcePackage,
1748
- mainDocumentPath,
1749
- documentPart.relationships,
1750
- FONT_TABLE_RELATIONSHIP_TYPE,
1751
- FONT_TABLE_PART_PATH,
1752
- );
1753
- const parsedFontTable =
1754
- fontTablePartPath && sourcePackage.parts.has(fontTablePartPath)
1755
- ? parseFontTable(
1756
- decodeUtf8(sourcePackage.parts.get(fontTablePartPath)?.bytes ?? new Uint8Array()),
1757
- )
1758
- : undefined;
1759
-
1760
- const mergedMedia = mergeSecondaryStoryMediaCatalog(normalizedDocument.media, {
1761
- headers: parsedHeaders,
1762
- footers: parsedFooters,
1763
- footnoteCollection,
1764
- mediaParts,
1765
- });
1766
-
1767
- const subParts: SubPartsCatalog | undefined =
1768
- parsedHeaders.length > 0 ||
1769
- parsedFooters.length > 0 ||
1770
- footnoteCollection !== undefined ||
1771
- parsedTheme !== undefined ||
1772
- normalizedDocument.finalSectionProperties !== undefined ||
1773
- resolvedTheme !== undefined ||
1774
- canonicalTheme !== undefined ||
1775
- parsedSettings !== undefined
1776
- ? {
1777
- headers: parsedHeaders,
1778
- footers: parsedFooters,
1779
- ...(footnoteCollection !== undefined ? { footnoteCollection } : {}),
1780
- ...(parsedTheme !== undefined ? { theme: parsedTheme } : {}),
1781
- ...(normalizedDocument.finalSectionProperties !== undefined
1782
- ? { finalSectionProperties: normalizedDocument.finalSectionProperties }
1783
- : {}),
1784
- ...(resolvedTheme !== undefined ? { resolvedTheme } : {}),
1785
- ...(canonicalTheme !== undefined ? { canonicalTheme } : {}),
1786
- ...(parsedSettings !== undefined ? { settings: parsedSettings } : {}),
1787
- }
1788
- : undefined;
1789
-
1790
- const timestamp = new Date().toISOString();
1791
- const translatedWorkflowState = translateClmCommentsToWorkflow({
1792
- comments: normalizedComments.threads,
1793
- workflowOverlay: embeddedWorkflowOverlay,
1794
- workflowMetadata: embeddedWorkflowMetadata,
1795
- timestamp,
1796
- });
1797
- const importedDocument = createImportedCanonicalDocument({
1798
- documentId: options.documentId,
1799
- timestamp,
1800
- numbering: parsedNumbering,
1801
- media: mergedMedia,
1802
- content: normalizedDocument.content,
1803
- subParts,
1804
- parsedStyles,
1805
- fontTable: parsedFontTable,
1806
- preservation: {
1807
- ...normalizedDocument.preservation,
1808
- packageParts: {
1809
- ...normalizedDocument.preservation.packageParts,
1810
- ...collectPreservedPackageParts(sourcePackage, [
1811
- mainDocumentPath,
1812
- numberingPartPath,
1813
- commentsPartPath,
1814
- commentsExtendedPartPath,
1815
- commentsIdsPartPath,
1816
- peoplePartPath,
1817
- ]),
1818
- },
1819
- },
1820
- diagnostics: {
1821
- warnings: [
1822
- ...createBrokenRelationshipWarnings(sourcePackage, mainDocumentPath),
1823
- ...normalizedDocument.diagnostics.warnings,
1824
- ...normalizedRevisions.diagnostics.map((diagnostic, index) => ({
1825
- diagnosticId: `diagnostic:revision-import-${index + 1}`,
1826
- warningId: `warning:revision-import-${diagnostic.revisionId}`,
1827
- source: "review" as const,
1828
- message: diagnostic.message,
1829
- })),
1830
- ...importedStoryRevisionDiagnostics.map((diagnostic, index) => ({
1831
- diagnosticId: `diagnostic:story-revision-import-${index + 1}`,
1832
- warningId: `warning:story-revision-import-${diagnostic.revisionId}`,
1833
- source: "review" as const,
1834
- message: diagnostic.message,
1835
- })),
1836
- ...normalizedComments.diagnostics.map((diagnostic, index) => ({
1837
- diagnosticId: `diagnostic:comment-import-${index + 1}`,
1838
- warningId: `warning:comment-import-${diagnostic.commentId}`,
1839
- source: "review" as const,
1840
- message: diagnostic.message,
1841
- })),
1842
- ],
1843
- errors: [],
1844
- },
1845
- review: {
1846
- comments: toRuntimeCommentRecords(translatedWorkflowState.comments),
1847
- revisions: toRuntimeRevisionRecords([
1848
- ...normalizedRevisions.revisions,
1849
- ...importedStoryRevisions,
1850
- ]),
1851
- },
1852
- });
1853
- // Stage 0B.1: if the host implements `renderChartPreview`, resolve
1854
- // chart_preview nodes inline so the first snapshot already carries the
1855
- // synthesized `previewMediaId`. Fallback-safe: returning null or throwing
1856
- // is per-chart — the typed badge renders as if the adapter weren't set.
1857
- // C3b: when onChartPreviewsReady is provided, defer resolution out of
1858
- // the critical path (same pattern as the short-circuit branch above).
1859
- let document: CanonicalDocumentEnvelope;
1860
- if (options.onChartPreviewsReady) {
1861
- scheduleChartPreviewResolution(
1862
- importedDocument,
1863
- sourcePackage,
1864
- options.hostAdapter,
1865
- options.onChartPreviewsReady,
1866
- );
1867
- document = importedDocument as CanonicalDocumentEnvelope;
1868
- } else {
1869
- document = (await resolveChartPreviewsForDocument(
1870
- importedDocument,
1871
- sourcePackage,
1872
- options.hostAdapter,
1873
- )) as CanonicalDocumentEnvelope;
1874
- }
1875
- const compatibility = buildCompatibilityReport({
1876
- document,
1877
- generatedAt: timestamp,
1878
- });
1879
- await scheduler.yield();
1880
- const snapshot = createImportedSnapshot({
1881
- documentId: options.documentId,
1882
- editorBuild,
1883
- timestamp,
1884
- document,
1885
- compatibility: toPublicCompatibilityReport(compatibility),
1886
- protectionSnapshot: importedProtectionSnapshot,
1887
- sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
1888
- workflowOverlay: translatedWorkflowState.workflowOverlay,
1889
- workflowMetadata: translatedWorkflowState.workflowMetadata,
1890
- });
1891
- const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
1892
- if (snapshotIssues.length > 0) {
1893
- const firstIssue = snapshotIssues[0];
1894
- return createDiagnosticsSession(
1895
- options,
1896
- createValidationImportDiagnostics({
1897
- message: `DOCX import produced an invalid editor state during validation${firstIssue ? ` (${firstIssue.path}: ${firstIssue.message})` : "."}`,
1898
- source: "import",
1899
- details: {
1900
- issueCount: snapshotIssues.length,
1901
- firstIssuePath: firstIssue?.path,
1902
- },
1903
- }),
1904
- );
1905
- }
1906
- const initialSessionState = editorSessionStateFromPersistedSnapshot(snapshot);
1907
- const importedState: ImportedDocxState = {
1908
- sourceBytes: new Uint8Array(sourceBytes),
1909
- sourcePackage,
1910
- sourceDocumentXml,
1911
- sourceDocumentPartPath: mainDocumentPath,
1912
- sourceDocumentRelationships: documentPart.relationships,
1913
- sourceDocumentAttributes: extractDocumentRootAttributes(sourceDocumentXml),
1914
- sourceNumberingPartPath: numberingPartPath,
1915
- sourceNumberingRelationshipId: documentPart.relationships.find(
1916
- (relationship) =>
1917
- relationship.type === NUMBERING_RELATIONSHIP_TYPE &&
1918
- relationship.targetMode === "internal",
1919
- )?.id,
1920
- sourceSettingsPartPath: settingsPartPath,
1921
- sourceSettingsXml:
1922
- settingsXmlForProtection.length > 0 ? settingsXmlForProtection : undefined,
1923
- sourceCommentsPartPath: commentsPartPath,
1924
- sourceCommentsRelationshipId: documentPart.relationships.find(
1925
- (relationship) =>
1926
- relationship.type === COMMENTS_RELATIONSHIP_TYPE &&
1927
- relationship.targetMode === "internal",
1928
- )?.id,
1929
- sourceCommentsRootTag: normalizedComments.sourceRootTag,
1930
- sourceCommentsExtendedPartPath: commentsExtendedPartPath,
1931
- sourceCommentsExtendedRelationshipId: documentPart.relationships.find(
1932
- (relationship) =>
1933
- relationship.type === COMMENTS_EXTENDED_RELATIONSHIP_TYPE &&
1934
- relationship.targetMode === "internal",
1935
- )?.id,
1936
- sourceCommentsExtendedRootTag: normalizedComments.sourceExtendedRootTag,
1937
- sourceCommentsIdsPartPath: commentsIdsPartPath,
1938
- sourceCommentsIdsRelationshipId: documentPart.relationships.find(
1939
- (relationship) =>
1940
- relationship.type === COMMENTS_IDS_RELATIONSHIP_TYPE &&
1941
- relationship.targetMode === "internal",
1942
- )?.id,
1943
- sourceCommentsIdsRootTag: normalizedComments.sourceIdsRootTag,
1944
- sourcePeoplePartPath: peoplePartPath,
1945
- sourcePeopleRelationshipId: documentPart.relationships.find(
1946
- (relationship) =>
1947
- relationship.type === PEOPLE_RELATIONSHIP_TYPE &&
1948
- relationship.targetMode === "internal",
1949
- )?.id,
1950
- sourcePeopleRootTag: normalizedComments.sourcePeopleRootTag,
1951
- sourcePeopleAuthors: normalizedComments.peopleAuthors,
1952
- protectionSnapshot: buildProtectionSnapshot(documentProtection, protectionRanges),
1953
- preservedCommentDefinitions: normalizedComments.preservedDefinitions,
1954
- blockingCommentDiagnostics: normalizedComments.diagnostics.filter((diagnostic) =>
1955
- BLOCKING_COMMENT_DIAGNOSTIC_CODES.has(diagnostic.code),
1956
- ),
1957
- initialCanonicalSignature: serializeCanonicalDocumentForExport(document),
1958
- sourceSubPartPaths: {
1959
- headers: sourceHeaderPaths,
1960
- footers: sourceFooterPaths,
1961
- footnotesPartPath,
1962
- footnotesRelationshipId,
1963
- endnotesPartPath,
1964
- endnotesRelationshipId,
1965
- themePartPath,
1966
- themeRelationshipId: themeRelationship?.id,
1967
- },
1968
- };
1969
-
1970
- stages.emit("skeleton-ready");
1971
- return {
1972
- initialSessionState,
1973
- initialSnapshot: snapshot,
1974
- readOnly: false,
1975
- protectionSnapshot: importedProtectionSnapshot,
1976
- exportDocx: async (nextSessionState, exportOptions) =>
1977
- exportDocxEditorSession(importedState, nextSessionState, exportOptions),
1978
- ...(embeddedWorkflowPayload?.editorState
1979
- ? { initialEditorStatePayload: embeddedWorkflowPayload.editorState }
1980
- : {}),
1981
- };
1982
- } catch (error) {
1983
- return createDiagnosticsSession(
1984
- options,
1985
- createImportDiagnosticsFromError(error),
1986
- );
1987
- }
1988
- }
1989
-
1990
- function loadStageNow(): number {
1991
- return typeof performance !== "undefined" && typeof performance.now === "function"
1992
- ? performance.now()
1993
- : Date.now();
1994
- }
1995
-
1996
- function exportDocxEditorSession(
1997
- state: ImportedDocxState,
1998
- sessionStateOrSnapshot: EditorSessionState | PersistedEditorSnapshot,
1999
- options?: ExportDocxOptions,
2000
- ): ExportResult {
2001
- const sessionState = toEditorSessionState(sessionStateOrSnapshot);
2002
-
2003
- if (sessionState.compatibility.blockExport) {
2004
- throw new Error("DOCX export is blocked by the current compatibility report.");
2005
- }
2006
-
2007
- const currentDocument = sessionState.canonicalDocument as CanonicalDocumentEnvelope;
2008
- const signatureMatch = serializeCanonicalDocumentForExport(currentDocument) ===
2009
- state.initialCanonicalSignature;
2010
- const canReuse = canReuseSourceBytesForCurrentDocument(state, currentDocument);
2011
- const durableWorkflowMetadata = getDocumentBackedWorkflowMetadata(sessionState.workflowMetadata);
2012
- const hasWorkflowOverlay = Boolean(sessionState.workflowOverlay);
2013
- const sourceHasWorkflowPayload = resolvePayloadPartPath(state.sourcePackage) !== null;
2014
- const commentCount = Object.keys(currentDocument.review?.comments ?? {}).length;
2015
- const currentRevisions = toReviewRevisionRecords(currentDocument.review.revisions);
2016
- const hasActiveImportedPreserveOnlyRevisions = currentRevisions.some(
2017
- (revision) =>
2018
- revision.status === "active" &&
2019
- revision.metadata.source === "import" &&
2020
- typeof revision.metadata.preserveOnlyReason === "string" &&
2021
- revision.metadata.preserveOnlyReason.length > 0,
2022
- );
2023
-
2024
- if (
2025
- signatureMatch &&
2026
- canReuse &&
2027
- durableWorkflowMetadata.definitions.length === 0 &&
2028
- durableWorkflowMetadata.entries.length === 0 &&
2029
- !hasWorkflowOverlay &&
2030
- !sourceHasWorkflowPayload
2031
- ) {
2032
- return {
2033
- bytes: new Uint8Array(state.sourceBytes),
2034
- mimeType: DOCX_MIME_TYPE,
2035
- fileName: options?.fileName ?? `${sessionState.documentId}.docx`,
2036
- delivery: {
2037
- mode: "exported-bytes-only",
2038
- },
2039
- };
2040
- }
2041
- const preservedCommentIds = new Set(
2042
- state.preservedCommentDefinitions.map((definition) => definition.commentId),
2043
- );
2044
- const blockingCommentCount = Math.max(
2045
- state.blockingCommentDiagnostics.length,
2046
- preservedCommentIds.size,
2047
- );
2048
- if (blockingCommentCount > 0) {
2049
- throw new Error(
2050
- `DOCX export is blocked because ${blockingCommentCount} preserve-only comment anchors cannot be safely remapped after runtime edits.`,
2051
- );
2052
- }
2053
- const actionableRevisions = currentRevisions.filter(
2054
- (revision) => getRevisionActionability(revision) === "actionable",
2055
- );
2056
- const mainStoryActionableRevisions = actionableRevisions.filter((revision) =>
2057
- !revision.metadata.storyTarget?.kind || revision.metadata.storyTarget.kind === "main"
2058
- );
2059
- const commentThreads = Object.values(
2060
- createCommentStoreFromRuntimeComments(currentDocument.review.comments).threads,
2061
- );
2062
- const ownedCommentThreads = commentThreads.filter(
2063
- (thread) => !preservedCommentIds.has(thread.commentId),
2064
- );
2065
- const revisionReadyMainContent = {
2066
- ...currentDocument.content,
2067
- children: splitStoryBlocksForRuntimeRevisions(
2068
- currentDocument.content.children,
2069
- mainStoryActionableRevisions,
2070
- ),
2071
- };
2072
- const serialized = serializeMainDocument(
2073
- splitDocumentAtReviewBoundaries(
2074
- revisionReadyMainContent as never,
2075
- ownedCommentThreads,
2076
- mainStoryActionableRevisions,
2077
- ) as never,
2078
- currentDocument.preservation as never,
2079
- state.sourceDocumentRelationships,
2080
- {
2081
- documentAttributes: state.sourceDocumentAttributes,
2082
- media: currentDocument.media as MediaCatalog,
2083
- finalSectionProperties: currentDocument.subParts?.finalSectionProperties,
2084
- namespaceFlavor: options?.exportStrictOoxml ? "strict" : "transitional",
2085
- },
2086
- );
2087
- const revisionDocument = serializeRuntimeRevisionsIntoDocumentXml(
2088
- serialized.documentXml,
2089
- mainStoryActionableRevisions,
2090
- );
2091
- if (revisionDocument.skippedRevisionIds.length > 0) {
2092
- throw new Error(
2093
- `DOCX export is blocked because ${revisionDocument.skippedRevisionIds.length} active revisions overlap unsupported serialization boundaries.`,
2094
- );
2095
- }
2096
-
2097
- const strippedDocumentXml = stripCommentMarkup(
2098
- revisionDocument.documentXml,
2099
- ownedCommentThreads.map((thread) => thread.commentId),
2100
- );
2101
- const exportCommentIds = createCommentExportIdMap(
2102
- ownedCommentThreads,
2103
- state.preservedCommentDefinitions,
2104
- );
2105
- const serializedComments = serializeMergedCommentsXml(ownedCommentThreads, {
2106
- exportCommentIds,
2107
- preservedDefinitions: state.preservedCommentDefinitions,
2108
- sourceRootTag: state.sourceCommentsRootTag,
2109
- sourceExtendedRootTag: state.sourceCommentsExtendedRootTag,
2110
- sourceIdsRootTag: state.sourceCommentsIdsRootTag,
2111
- sourcePeopleRootTag: state.sourcePeopleRootTag,
2112
- peopleAuthors: state.sourcePeopleAuthors,
2113
- });
2114
- const annotatedDocument = serializeCommentAnchorsIntoDocumentXml(
2115
- strippedDocumentXml,
2116
- ownedCommentThreads,
2117
- undefined,
2118
- {
2119
- exportCommentIds,
2120
- },
2121
- );
2122
- const protectedDocumentXml = serializeProtectionRangesIntoDocumentXml(
2123
- annotatedDocument.documentXml,
2124
- state.protectionSnapshot,
2125
- );
2126
- const blockingSkippedCommentIds = annotatedDocument.skippedCommentIds.filter((commentId) => {
2127
- const thread = ownedCommentThreads.find((candidate) => candidate.commentId === commentId);
2128
- return !thread || thread.anchor.kind !== "detached";
2129
- });
2130
- if (blockingSkippedCommentIds.length > 0) {
2131
- throw new Error(
2132
- `DOCX export is blocked because ${blockingSkippedCommentIds.length} comments no longer map to serializable ranges.`,
2133
- );
2134
- }
2135
- const commentsPartPath =
2136
- state.sourceCommentsPartPath ?? COMMENTS_PART_PATH;
2137
- const commentsExtendedPartPath =
2138
- state.sourceCommentsExtendedPartPath ?? COMMENTS_EXTENDED_PART_PATH;
2139
- const commentsIdsPartPath =
2140
- state.sourceCommentsIdsPartPath ?? COMMENTS_IDS_PART_PATH;
2141
- const peoplePartPath =
2142
- state.sourcePeoplePartPath ?? PEOPLE_PART_PATH;
2143
- const numberingPartPath =
2144
- state.sourceNumberingPartPath ?? NUMBERING_PART_PATH;
2145
- const serializedNumberingXml = hasSerializableNumberingEntries(
2146
- currentDocument.numbering as NumberingCatalog,
2147
- )
2148
- ? serializeNumberingXml(currentDocument.numbering as NumberingCatalog)
2149
- : undefined;
2150
- const nextRelationships = withDocumentRelatedParts(
2151
- serialized.relationships,
2152
- [
2153
- {
2154
- relationshipType: NUMBERING_RELATIONSHIP_TYPE,
2155
- partPath: numberingPartPath,
2156
- existingRelationshipId: state.sourceNumberingRelationshipId,
2157
- include:
2158
- Boolean(serializedNumberingXml) ||
2159
- Boolean(state.sourceNumberingPartPath),
2160
- },
2161
- {
2162
- relationshipType: COMMENTS_RELATIONSHIP_TYPE,
2163
- partPath: commentsPartPath,
2164
- existingRelationshipId: state.sourceCommentsRelationshipId,
2165
- include:
2166
- serializedComments.serializedCommentIds.length > 0 ||
2167
- Boolean(state.sourceCommentsPartPath),
2168
- },
2169
- {
2170
- relationshipType: COMMENTS_EXTENDED_RELATIONSHIP_TYPE,
2171
- partPath: commentsExtendedPartPath,
2172
- existingRelationshipId: state.sourceCommentsExtendedRelationshipId,
2173
- include:
2174
- Boolean(serializedComments.commentsExtendedXml) ||
2175
- Boolean(state.sourceCommentsExtendedPartPath),
2176
- },
2177
- {
2178
- relationshipType: COMMENTS_IDS_RELATIONSHIP_TYPE,
2179
- partPath: commentsIdsPartPath,
2180
- existingRelationshipId: state.sourceCommentsIdsRelationshipId,
2181
- include:
2182
- Boolean(serializedComments.commentsIdsXml) ||
2183
- Boolean(state.sourceCommentsIdsPartPath),
2184
- },
2185
- {
2186
- relationshipType: PEOPLE_RELATIONSHIP_TYPE,
2187
- partPath: peoplePartPath,
2188
- existingRelationshipId: state.sourcePeopleRelationshipId,
2189
- include:
2190
- Boolean(serializedComments.peopleXml) ||
2191
- Boolean(state.sourcePeoplePartPath),
2192
- },
2193
- ],
2194
- );
2195
-
2196
- const exportedSubParts = currentDocument.subParts as SubPartsCatalog | undefined;
2197
- const subPartOwnedPaths: string[] = [];
2198
- if (exportedSubParts) {
2199
- for (const header of exportedSubParts.headers) {
2200
- subPartOwnedPaths.push(header.partPath);
2201
- }
2202
- for (const footer of exportedSubParts.footers) {
2203
- subPartOwnedPaths.push(footer.partPath);
2204
- }
2205
- if (exportedSubParts.footnoteCollection) {
2206
- if (state.sourceSubPartPaths.footnotesPartPath) {
2207
- subPartOwnedPaths.push(state.sourceSubPartPaths.footnotesPartPath);
2208
- }
2209
- if (state.sourceSubPartPaths.endnotesPartPath) {
2210
- subPartOwnedPaths.push(state.sourceSubPartPaths.endnotesPartPath);
2211
- }
2212
- }
2213
- if (exportedSubParts.theme && state.sourceSubPartPaths.themePartPath) {
2214
- subPartOwnedPaths.push(state.sourceSubPartPaths.themePartPath);
2215
- }
2216
- }
2217
-
2218
- // Settings.xml is owned when the source package carried one OR the canonical
2219
- // model carries settings we need to re-emit. The `canReuse && signatureMatch`
2220
- // short-circuit above already skips re-export entirely for no-edit sessions,
2221
- // so every path that reaches here is willing to emit a rebuilt settings.xml.
2222
- const settingsPartPath =
2223
- state.sourceSettingsPartPath ?? SETTINGS_PART_PATH;
2224
- const hasSettingsSurface =
2225
- Boolean(state.sourceSettingsPartPath) ||
2226
- exportedSubParts?.settings !== undefined;
2227
- const workflowPayloadPartPaths = resolveWorkflowPayloadPartPaths(
2228
- state.sourcePackage,
2229
- sessionState.documentId,
2230
- );
2231
- const internalEditorState = (
2232
- options as { _editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload } | undefined
2233
- )?._editorState;
2234
-
2235
- const exportSession = createExportSession(state.sourcePackage, [
2236
- state.sourceDocumentPartPath,
2237
- APP_PROPERTIES_PART_PATH,
2238
- CORE_PROPERTIES_PART_PATH,
2239
- workflowPayloadPartPaths.payloadPartPath,
2240
- workflowPayloadPartPaths.itemPropsPartPath,
2241
- WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
2242
- numberingPartPath,
2243
- commentsPartPath,
2244
- commentsExtendedPartPath,
2245
- commentsIdsPartPath,
2246
- peoplePartPath,
2247
- ...(hasSettingsSurface ? [settingsPartPath] : []),
2248
- ...subPartOwnedPaths,
2249
- ]);
2250
-
2251
- const mainDocumentXmlForExport =
2252
- signatureMatch &&
2253
- durableWorkflowMetadata.definitions.length === 0 &&
2254
- durableWorkflowMetadata.entries.length === 0 &&
2255
- !hasWorkflowOverlay &&
2256
- commentCount === 0 &&
2257
- hasActiveImportedPreserveOnlyRevisions
2258
- ? state.sourceDocumentXml
2259
- : protectedDocumentXml;
2260
-
2261
- exportSession.replaceOwnedPart({
2262
- path: state.sourceDocumentPartPath,
2263
- bytes: new TextEncoder().encode(mainDocumentXmlForExport),
2264
- contentType: MAIN_DOCUMENT_CONTENT_TYPE,
2265
- relationships: nextRelationships,
2266
- });
2267
-
2268
- if (serializedNumberingXml || state.sourceNumberingPartPath) {
2269
- exportSession.replaceOwnedPart({
2270
- path: numberingPartPath,
2271
- bytes: new TextEncoder().encode(
2272
- serializedNumberingXml ??
2273
- `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"></w:numbering>`,
2274
- ),
2275
- contentType:
2276
- state.sourcePackage.parts.get(numberingPartPath)?.contentType ??
2277
- WORD_NUMBERING_CONTENT_TYPE,
2278
- });
2279
- }
2280
-
2281
- if (hasSettingsSurface) {
2282
- // Canonical settings ∅ + no source settings → omit the owned-part write
2283
- // (hasSettingsSurface is already false in that case). Otherwise route
2284
- // through the graft serializer so unmodelled children round-trip via
2285
- // source bytes while canonical mutations land.
2286
- const canonicalSettings = exportedSubParts?.settings ?? {};
2287
- const settingsXml = serializeSettingsXml(canonicalSettings, state.sourceSettingsXml);
2288
- exportSession.replaceOwnedPart({
2289
- path: settingsPartPath,
2290
- bytes: new TextEncoder().encode(settingsXml),
2291
- contentType:
2292
- state.sourcePackage.parts.get(settingsPartPath)?.contentType ??
2293
- WORD_SETTINGS_CONTENT_TYPE,
2294
- });
2295
- }
2296
-
2297
- if (serializedComments.serializedCommentIds.length > 0 || state.sourceCommentsPartPath) {
2298
- exportSession.replaceOwnedPart({
2299
- path: commentsPartPath,
2300
- bytes: new TextEncoder().encode(serializedComments.commentsXml),
2301
- contentType:
2302
- state.sourcePackage.parts.get(commentsPartPath)?.contentType ?? COMMENTS_CONTENT_TYPE,
2303
- });
2304
- }
2305
-
2306
- if (serializedComments.commentsExtendedXml || state.sourceCommentsExtendedPartPath) {
2307
- exportSession.replaceOwnedPart({
2308
- path: commentsExtendedPartPath,
2309
- bytes: new TextEncoder().encode(
2310
- serializedComments.commentsExtendedXml ?? `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w15:commentsEx xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"></w15:commentsEx>`,
2311
- ),
2312
- contentType:
2313
- state.sourcePackage.parts.get(commentsExtendedPartPath)?.contentType ??
2314
- COMMENTS_EXTENDED_CONTENT_TYPE,
2315
- });
2316
- }
2317
-
2318
- if (serializedComments.commentsIdsXml || state.sourceCommentsIdsPartPath) {
2319
- exportSession.replaceOwnedPart({
2320
- path: commentsIdsPartPath,
2321
- bytes: new TextEncoder().encode(
2322
- serializedComments.commentsIdsXml ?? `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w16cid:commentsIds xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid"></w16cid:commentsIds>`,
2323
- ),
2324
- contentType:
2325
- state.sourcePackage.parts.get(commentsIdsPartPath)?.contentType ??
2326
- COMMENTS_IDS_CONTENT_TYPE,
2327
- });
2328
- }
2329
-
2330
- if (serializedComments.peopleXml || state.sourcePeoplePartPath) {
2331
- exportSession.replaceOwnedPart({
2332
- path: peoplePartPath,
2333
- bytes: new TextEncoder().encode(
2334
- serializedComments.peopleXml ?? `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w15:people xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"></w15:people>`,
2335
- ),
2336
- contentType:
2337
- state.sourcePackage.parts.get(peoplePartPath)?.contentType ??
2338
- PEOPLE_CONTENT_TYPE,
2339
- });
2340
- }
2341
-
2342
- if (exportedSubParts) {
2343
- const headersByPartPath = new Map<
2344
- string,
2345
- { header: (typeof exportedSubParts.headers)[number]; revisions: ReviewRevisionRecord[] }
2346
- >();
2347
- for (const header of exportedSubParts.headers) {
2348
- const entry = headersByPartPath.get(header.partPath) ?? { header, revisions: [] };
2349
- const matchingRevisions = actionableRevisions.filter((revision) =>
2350
- storyTargetsEqual(revision.metadata.storyTarget ?? { kind: "main" }, {
2351
- kind: "header",
2352
- relationshipId: header.relationshipId,
2353
- variant: header.variant,
2354
- ...(header.sectionIndex !== undefined ? { sectionIndex: header.sectionIndex } : {}),
2355
- })
2356
- );
2357
- entry.revisions.push(...matchingRevisions);
2358
- if (matchingRevisions.length > 0) {
2359
- entry.header = header;
2360
- }
2361
- headersByPartPath.set(header.partPath, entry);
2362
- }
2363
- for (const { header, revisions } of headersByPartPath.values()) {
2364
- const serializedHeaderXml = serializeSecondaryStoryWithRuntimeRevisions(
2365
- serializeHeaderXmlWithRevisions(header, revisions),
2366
- revisions,
2367
- `header ${header.partPath}`,
2368
- );
2369
- exportSession.replaceOwnedPart({
2370
- path: header.partPath,
2371
- bytes: new TextEncoder().encode(serializedHeaderXml),
2372
- contentType:
2373
- state.sourcePackage.parts.get(header.partPath)?.contentType ?? WORD_HEADER_CONTENT_TYPE,
2374
- });
2375
- }
2376
- const footersByPartPath = new Map<
2377
- string,
2378
- { footer: (typeof exportedSubParts.footers)[number]; revisions: ReviewRevisionRecord[] }
2379
- >();
2380
- for (const footer of exportedSubParts.footers) {
2381
- const entry = footersByPartPath.get(footer.partPath) ?? { footer, revisions: [] };
2382
- const matchingRevisions = actionableRevisions.filter((revision) =>
2383
- storyTargetsEqual(revision.metadata.storyTarget ?? { kind: "main" }, {
2384
- kind: "footer",
2385
- relationshipId: footer.relationshipId,
2386
- variant: footer.variant,
2387
- ...(footer.sectionIndex !== undefined ? { sectionIndex: footer.sectionIndex } : {}),
2388
- })
2389
- );
2390
- entry.revisions.push(...matchingRevisions);
2391
- if (matchingRevisions.length > 0) {
2392
- entry.footer = footer;
2393
- }
2394
- footersByPartPath.set(footer.partPath, entry);
2395
- }
2396
- for (const { footer, revisions } of footersByPartPath.values()) {
2397
- const serializedFooterXml = serializeSecondaryStoryWithRuntimeRevisions(
2398
- serializeFooterXmlWithRevisions(footer, revisions),
2399
- revisions,
2400
- `footer ${footer.partPath}`,
2401
- );
2402
- exportSession.replaceOwnedPart({
2403
- path: footer.partPath,
2404
- bytes: new TextEncoder().encode(serializedFooterXml),
2405
- contentType:
2406
- state.sourcePackage.parts.get(footer.partPath)?.contentType ?? WORD_FOOTER_CONTENT_TYPE,
2407
- });
2408
- }
2409
- if (exportedSubParts.footnoteCollection) {
2410
- if (state.sourceSubPartPaths.footnotesPartPath) {
2411
- const serializedFootnotesXml = serializeFootnotesXml(
2412
- exportedSubParts.footnoteCollection,
2413
- Object.fromEntries(
2414
- actionableRevisions
2415
- .filter((revision) => revision.metadata.storyTarget?.kind === "footnote")
2416
- .map((revision) =>
2417
- revision.metadata.storyTarget?.kind === "footnote"
2418
- ? [revision.metadata.storyTarget.noteId, [] as ReviewRevisionRecord[]] as const
2419
- : null,
2420
- )
2421
- .filter((entry): entry is readonly [string, ReviewRevisionRecord[]] => entry !== null)
2422
- .map(([noteId]) => [
2423
- noteId,
2424
- actionableRevisions.filter(
2425
- (revision) =>
2426
- revision.metadata.storyTarget?.kind === "footnote" &&
2427
- revision.metadata.storyTarget.noteId === noteId,
2428
- ),
2429
- ]),
2430
- ),
2431
- );
2432
- exportSession.replaceOwnedPart({
2433
- path: state.sourceSubPartPaths.footnotesPartPath,
2434
- bytes: new TextEncoder().encode(serializedFootnotesXml),
2435
- contentType:
2436
- state.sourcePackage.parts.get(state.sourceSubPartPaths.footnotesPartPath)?.contentType ??
2437
- WORD_FOOTNOTES_CONTENT_TYPE,
2438
- });
2439
- }
2440
- if (state.sourceSubPartPaths.endnotesPartPath) {
2441
- const serializedEndnotesXml = serializeEndnotesXml(
2442
- exportedSubParts.footnoteCollection,
2443
- Object.fromEntries(
2444
- actionableRevisions
2445
- .filter((revision) => revision.metadata.storyTarget?.kind === "endnote")
2446
- .map((revision) =>
2447
- revision.metadata.storyTarget?.kind === "endnote"
2448
- ? [revision.metadata.storyTarget.noteId, [] as ReviewRevisionRecord[]] as const
2449
- : null,
2450
- )
2451
- .filter((entry): entry is readonly [string, ReviewRevisionRecord[]] => entry !== null)
2452
- .map(([noteId]) => [
2453
- noteId,
2454
- actionableRevisions.filter(
2455
- (revision) =>
2456
- revision.metadata.storyTarget?.kind === "endnote" &&
2457
- revision.metadata.storyTarget.noteId === noteId,
2458
- ),
2459
- ]),
2460
- ),
2461
- );
2462
- exportSession.replaceOwnedPart({
2463
- path: state.sourceSubPartPaths.endnotesPartPath,
2464
- bytes: new TextEncoder().encode(serializedEndnotesXml),
2465
- contentType:
2466
- state.sourcePackage.parts.get(state.sourceSubPartPaths.endnotesPartPath)?.contentType ??
2467
- WORD_ENDNOTES_CONTENT_TYPE,
2468
- });
2469
- }
2470
- }
2471
- if (exportedSubParts.theme && state.sourceSubPartPaths.themePartPath) {
2472
- const sourceThemePart = state.sourcePackage.parts.get(state.sourceSubPartPaths.themePartPath);
2473
- if (sourceThemePart) {
2474
- exportSession.replaceOwnedPart({
2475
- path: state.sourceSubPartPaths.themePartPath,
2476
- bytes: sourceThemePart.bytes,
2477
- contentType:
2478
- sourceThemePart.contentType ??
2479
- "application/vnd.openxmlformats-officedocument.theme+xml",
2480
- relationships: sourceThemePart.relationships,
2481
- compression: sourceThemePart.compression,
2482
- });
2483
- }
2484
- }
2485
- }
2486
-
2487
- ensureHostMetadataParts(exportSession, state.sourcePackage, currentDocument);
2488
- // Schema 1.2: pass through editorState payload collected by the runtime channel.
2489
- ensureWorkflowPayloadParts(
2490
- exportSession,
2491
- sessionState,
2492
- currentDocument,
2493
- state.sourcePackage,
2494
- workflowPayloadPartPaths,
2495
- internalEditorState,
2496
- );
2497
-
2498
- return {
2499
- bytes: exportSession.serialize(),
2500
- mimeType: DOCX_MIME_TYPE,
2501
- fileName: options?.fileName ?? `${sessionState.documentId}.docx`,
2502
- delivery: {
2503
- mode: "exported-bytes-only",
2504
- },
2505
- };
2506
- }
2507
-
2508
- function toEditorSessionState(
2509
- sessionStateOrSnapshot: EditorSessionState | PersistedEditorSnapshot,
2510
- ): EditorSessionState {
2511
- return "sessionVersion" in sessionStateOrSnapshot
2512
- ? sessionStateOrSnapshot
2513
- : editorSessionStateFromPersistedSnapshot(sessionStateOrSnapshot);
2514
- }
2515
-
2516
- function createImportedCanonicalDocument(input: {
2517
- documentId: string;
2518
- timestamp: string;
2519
- numbering: CanonicalDocumentEnvelope["numbering"];
2520
- media: CanonicalDocumentEnvelope["media"];
2521
- content: CanonicalDocumentEnvelope["content"];
2522
- subParts?: SubPartsCatalog;
2523
- parsedStyles?: ParseStylesResult;
2524
- fontTable?: CanonicalDocumentEnvelope["fontTable"];
2525
- preservation: CanonicalDocumentEnvelope["preservation"];
2526
- diagnostics: CanonicalDocumentEnvelope["diagnostics"];
2527
- review: CanonicalDocumentEnvelope["review"];
2528
- }): CanonicalDocumentEnvelope {
2529
- const numbering = ensureImportedNumberingCatalogSupportsContent(
2530
- input.numbering,
2531
- input.content,
2532
- );
2533
-
2534
- // Use package-backed style catalog when available; fall back to synthetic
2535
- // styles derived from referenced styleId values when styles.xml is missing
2536
- // or could not be parsed.
2537
- const styles = buildStylesCatalog(input.parsedStyles, input.content, input.subParts);
2538
-
2539
- return {
2540
- schemaVersion: "cds/1.0.0",
2541
- docId: createCanonicalDocumentId(input.documentId),
2542
- createdAt: input.timestamp,
2543
- updatedAt: input.timestamp,
2544
- metadata: {
2545
- customProperties: {},
2546
- },
2547
- styles,
2548
- numbering,
2549
- media: input.media,
2550
- content: input.content,
2551
- review: input.review,
2552
- preservation: input.preservation,
2553
- diagnostics: input.diagnostics,
2554
- ...(input.subParts !== undefined ? { subParts: input.subParts } : {}),
2555
- ...(input.fontTable !== undefined ? { fontTable: input.fontTable } : {}),
2556
- };
2557
- }
2558
-
2559
- type SubPartOpaqueImportState = {
2560
- nextFragmentIndex: number;
2561
- nextWarningIndex: number;
2562
- nextDiagnosticIndex: number;
2563
- cursor: number;
2564
- };
2565
-
2566
- function createSubPartOpaqueImportState(
2567
- opaqueFragments: Record<string, OpaqueFragmentRecord>,
2568
- warnings: CanonicalDocumentEnvelope["diagnostics"]["warnings"],
2569
- ): SubPartOpaqueImportState {
2570
- const maxByPrefix = (values: Iterable<string>, prefix: string): number => {
2571
- let max = 0;
2572
- for (const value of values) {
2573
- const match = new RegExp(`^${prefix}(\\d+)$`).exec(value);
2574
- if (!match) {
2575
- continue;
2576
- }
2577
- const parsed = Number.parseInt(match[1] ?? "0", 10);
2578
- if (Number.isFinite(parsed) && parsed > max) {
2579
- max = parsed;
2580
- }
2581
- }
2582
- return max;
2583
- };
2584
-
2585
- const maxCursor = Object.values(opaqueFragments).reduce(
2586
- (currentMax, fragment) => Math.max(currentMax, fragment.lastKnownRange?.to ?? 0),
2587
- 0,
2588
- );
2589
-
2590
- return {
2591
- nextFragmentIndex:
2592
- maxByPrefix(Object.keys(opaqueFragments), "fragment:import-") + 1,
2593
- nextWarningIndex:
2594
- maxByPrefix(
2595
- [
2596
- ...Object.values(opaqueFragments).map((fragment) => fragment.warningId),
2597
- ...warnings.map((warning) => warning.warningId),
2598
- ],
2599
- "warning:import-",
2600
- ) + 1,
2601
- nextDiagnosticIndex:
2602
- maxByPrefix(warnings.map((warning) => warning.diagnosticId), "diagnostic:import-") + 1,
2603
- cursor: maxCursor,
2604
- };
2605
- }
2606
-
2607
- function normalizeSubPartOpaqueBlocks(
2608
- blocks: BlockNode[],
2609
- opaqueFragments: Record<string, OpaqueFragmentRecord>,
2610
- warnings: CanonicalDocumentEnvelope["diagnostics"]["warnings"],
2611
- packagePartName: string,
2612
- state: SubPartOpaqueImportState,
2613
- ): BlockNode[] {
2614
- return blocks.map((block) => {
2615
- if (block.type !== "opaque_block" || typeof block.rawXml !== "string") {
2616
- return block;
2617
- }
2618
- return recordImportedOpaqueBlock(
2619
- block.rawXml,
2620
- opaqueFragments,
2621
- warnings,
2622
- packagePartName,
2623
- state,
2624
- );
2625
- });
2626
- }
2627
-
2628
- function normalizeFootnoteCollectionOpaqueBlocks(
2629
- collection: FootnoteCollection | undefined,
2630
- kind: "footnote" | "endnote",
2631
- opaqueFragments: Record<string, OpaqueFragmentRecord>,
2632
- warnings: CanonicalDocumentEnvelope["diagnostics"]["warnings"],
2633
- packagePartName: string,
2634
- state: SubPartOpaqueImportState,
2635
- ): void {
2636
- if (!collection) {
2637
- return;
2638
- }
2639
- const notes = kind === "footnote" ? collection.footnotes : collection.endnotes;
2640
- for (const definition of Object.values(notes)) {
2641
- definition.blocks = normalizeSubPartOpaqueBlocks(
2642
- definition.blocks,
2643
- opaqueFragments,
2644
- warnings,
2645
- packagePartName,
2646
- state,
2647
- );
2648
- }
2649
- }
2650
-
2651
- function recordImportedOpaqueBlock(
2652
- rawXml: string,
2653
- opaqueFragments: Record<string, OpaqueFragmentRecord>,
2654
- warnings: CanonicalDocumentEnvelope["diagnostics"]["warnings"],
2655
- packagePartName: string,
2656
- state: SubPartOpaqueImportState,
2657
- ): BlockNode {
2658
- const fragmentId = `fragment:import-${state.nextFragmentIndex}`;
2659
- state.nextFragmentIndex += 1;
2660
- const warningId = `warning:import-${state.nextWarningIndex}`;
2661
- state.nextWarningIndex += 1;
2662
- const diagnosticId = `diagnostic:import-${state.nextDiagnosticIndex}`;
2663
- state.nextDiagnosticIndex += 1;
2664
-
2665
- const rangeStart = state.cursor;
2666
- const rangeEnd = state.cursor + 1;
2667
- state.cursor = rangeEnd;
2668
-
2669
- opaqueFragments[fragmentId] = {
2670
- fragmentId,
2671
- payloadKind: "xml-subtree",
2672
- payloadReference: rawXml,
2673
- featureClass: "preserve-only",
2674
- lastKnownRange: {
2675
- from: rangeStart,
2676
- to: rangeEnd,
2677
- },
2678
- warningId,
2679
- packagePartName,
2680
- };
2681
- warnings.push({
2682
- diagnosticId,
2683
- warningId,
2684
- source: "import",
2685
- message: "Unsupported sub-part OOXML was preserved as an opaque placeholder.",
2686
- });
2687
-
2688
- return {
2689
- type: "opaque_block",
2690
- fragmentId,
2691
- warningId,
2692
- rawXml,
2693
- };
2694
- }
2695
-
2696
- function mergeSecondaryStoryMediaCatalog(
2697
- media: MediaCatalog,
2698
- input: {
2699
- headers: readonly HeaderDocument[];
2700
- footers: readonly FooterDocument[];
2701
- footnoteCollection?: FootnoteCollection;
2702
- mediaParts: ReadonlyMap<string, { path: string; contentType: string }>;
2703
- },
2704
- ): MediaCatalog {
2705
- const items = { ...media.items };
2706
- let changed = false;
2707
-
2708
- const registerMediaItem = (
2709
- mediaId: string,
2710
- record: Omit<NonNullable<MediaCatalog["items"][string]>, "mediaId">,
2711
- ) => {
2712
- const existing = items[mediaId];
2713
- items[mediaId] = existing
2714
- ? {
2715
- ...existing,
2716
- ...record,
2717
- mediaId,
2718
- }
2719
- : {
2720
- mediaId,
2721
- ...record,
2722
- };
2723
- changed = true;
2724
- };
2725
-
2726
- const visitInline = (node: InlineNode) => {
2727
- if (node.type === "image") {
2728
- const packagePartName = `/${node.mediaId.slice("media:".length)}`;
2729
- registerMediaItem(node.mediaId, {
2730
- contentType:
2731
- items[node.mediaId]?.contentType ??
2732
- input.mediaParts.get(packagePartName)?.contentType ??
2733
- "application/octet-stream",
2734
- filename: packagePartName.slice(packagePartName.lastIndexOf("/") + 1) || "image.bin",
2735
- packagePartName,
2736
- ...(node.altText ? { altText: node.altText } : {}),
2737
- });
2738
- return;
2739
- }
2740
- if (node.type === "drawing_frame" && node.content.type === "picture" && node.content.mediaId) {
2741
- const packagePartName =
2742
- typeof node.content.packagePartName === "string" && node.content.packagePartName.length > 0
2743
- ? node.content.packagePartName
2744
- : `/${node.content.mediaId.slice("media:".length)}`;
2745
- registerMediaItem(node.content.mediaId, {
2746
- contentType:
2747
- items[node.content.mediaId]?.contentType ??
2748
- input.mediaParts.get(packagePartName)?.contentType ??
2749
- "application/octet-stream",
2750
- filename: packagePartName.slice(packagePartName.lastIndexOf("/") + 1) || "image.bin",
2751
- packagePartName,
2752
- relationshipId: node.content.blipRef,
2753
- ...(node.anchor.docPr?.descr ? { altText: node.anchor.docPr.descr } : {}),
2754
- widthEmu: node.anchor.extent.widthEmu,
2755
- heightEmu: node.anchor.extent.heightEmu,
2756
- });
2757
- return;
2758
- }
2759
- if (node.type === "hyperlink" || node.type === "field") {
2760
- for (const child of node.children) {
2761
- visitInline(child);
2762
- }
2763
- }
2764
- };
2765
-
2766
- const visitBlocks = (blocks: ReadonlyArray<BlockNode>) => {
2767
- for (const block of blocks) {
2768
- if (block.type === "paragraph") {
2769
- for (const child of block.children) {
2770
- visitInline(child);
2771
- }
2772
- continue;
2773
- }
2774
- if (block.type === "table") {
2775
- for (const row of block.rows) {
2776
- for (const cell of row.cells) {
2777
- visitBlocks(cell.children);
2778
- }
2779
- }
2780
- continue;
2781
- }
2782
- if (block.type === "sdt" || block.type === "custom_xml") {
2783
- visitBlocks(block.children);
2784
- }
2785
- }
2786
- };
2787
-
2788
- for (const header of input.headers) {
2789
- visitBlocks(header.blocks);
2790
- }
2791
- for (const footer of input.footers) {
2792
- visitBlocks(footer.blocks);
2793
- }
2794
- if (input.footnoteCollection) {
2795
- for (const note of Object.values(input.footnoteCollection.footnotes)) {
2796
- visitBlocks(note.blocks);
2797
- }
2798
- for (const note of Object.values(input.footnoteCollection.endnotes)) {
2799
- visitBlocks(note.blocks);
2800
- }
2801
- }
2802
-
2803
- return changed ? { ...media, items } : media;
2804
- }
2805
-
2806
- // Canonical model styleId validation pattern — styleIds that don't match
2807
- // are excluded from the catalog to avoid snapshot validation failures.
2808
- const VALID_STYLE_ID = /^[A-Za-z_][A-Za-z0-9._-]{0,127}$/;
2809
-
2810
- function buildStylesCatalog(
2811
- parsedStyles: ParseStylesResult | undefined,
2812
- content: CanonicalDocumentEnvelope["content"],
2813
- subParts?: SubPartsCatalog,
2814
- ): CanonicalDocumentEnvelope["styles"] {
2815
- if (parsedStyles?.fromPackage) {
2816
- // Package-backed catalog: filter entries whose styleId does not satisfy
2817
- // the canonical model pattern (e.g. numeric-only ids from Word).
2818
- const catalog = filterValidStyleIds(parsedStyles.catalog);
2819
-
2820
- // Merge in any referenced styleIds that the package styles.xml did not
2821
- // define (rare but defensive).
2822
- const referencedIds = collectReferencedParagraphStyleIds(content, subParts);
2823
- for (const styleId of referencedIds) {
2824
- if (!catalog.paragraphs[styleId] && VALID_STYLE_ID.test(styleId)) {
2825
- catalog.paragraphs[styleId] = {
2826
- styleId,
2827
- displayName: styleId,
2828
- kind: "paragraph",
2829
- isDefault: styleId === "Normal",
2830
- };
2831
- }
2832
- }
2833
- return {
2834
- ...catalog,
2835
- fromPackage: true,
2836
- };
2837
- }
2838
-
2839
- // Synthetic fallback: no styles.xml available
2840
- const paragraphStyles = Object.fromEntries(
2841
- [...collectReferencedParagraphStyleIds(content, subParts)]
2842
- .sort((left, right) => left.localeCompare(right))
2843
- .filter((styleId) => VALID_STYLE_ID.test(styleId))
2844
- .map((styleId) => [
2845
- styleId,
2846
- {
2847
- styleId,
2848
- displayName: styleId,
2849
- kind: "paragraph" as const,
2850
- isDefault: styleId === "Normal",
2851
- },
2852
- ]),
2853
- );
2854
- return {
2855
- paragraphs: paragraphStyles,
2856
- characters: {},
2857
- tables: {},
2858
- fromPackage: false,
2859
- };
2860
- }
2861
-
2862
- function filterValidStyleIds(
2863
- catalog: CanonicalDocumentEnvelope["styles"],
2864
- ): CanonicalDocumentEnvelope["styles"] {
2865
- const filterRecord = <T extends { styleId: string }>(
2866
- record: Record<string, T>,
2867
- ): Record<string, T> => {
2868
- const result: Record<string, T> = {};
2869
- for (const [key, value] of Object.entries(record)) {
2870
- if (VALID_STYLE_ID.test(key)) {
2871
- result[key] = value;
2872
- }
2873
- }
2874
- return result;
2875
- };
2876
-
2877
- return {
2878
- paragraphs: filterRecord(catalog.paragraphs),
2879
- characters: filterRecord(catalog.characters),
2880
- tables: filterRecord(catalog.tables),
2881
- ...(catalog.latentStyles ? { latentStyles: catalog.latentStyles } : {}),
2882
- ...(catalog.docDefaults ? { docDefaults: catalog.docDefaults } : {}),
2883
- ...(catalog.fromPackage !== undefined ? { fromPackage: catalog.fromPackage } : {}),
2884
- };
2885
- }
2886
-
2887
- function ensureImportedNumberingCatalogSupportsContent(
2888
- catalog: NumberingCatalog,
2889
- content: CanonicalDocumentEnvelope["content"],
2890
- ): NumberingCatalog {
2891
- if (
2892
- catalog.instances[DOCX_NULL_NUMBERING_INSTANCE_ID] ||
2893
- !collectReferencedNumberingInstanceIds(content).has(DOCX_NULL_NUMBERING_INSTANCE_ID)
2894
- ) {
2895
- return catalog;
2896
- }
2897
-
2898
- const syntheticNullCatalog = createSyntheticDocxNullNumberingCatalog();
2899
- return {
2900
- abstractDefinitions: {
2901
- ...catalog.abstractDefinitions,
2902
- ...syntheticNullCatalog.abstractDefinitions,
2903
- },
2904
- instances: {
2905
- ...catalog.instances,
2906
- ...syntheticNullCatalog.instances,
2907
- },
2908
- ...(catalog.numPicBullets !== undefined ? { numPicBullets: catalog.numPicBullets } : {}),
2909
- };
2910
- }
2911
-
2912
- function collectReferencedNumberingInstanceIds(
2913
- content: CanonicalDocumentEnvelope["content"],
2914
- ): Set<string> {
2915
- const numberingInstanceIds = new Set<string>();
2916
-
2917
- const visitBlocks = (blocks: ReadonlyArray<BlockNode>) => {
2918
- for (const block of blocks) {
2919
- if (block.type === "paragraph" && block.numbering?.numberingInstanceId) {
2920
- numberingInstanceIds.add(block.numbering.numberingInstanceId);
2921
- }
2922
- if (block.type === "table") {
2923
- for (const row of block.rows) {
2924
- for (const cell of row.cells) {
2925
- visitBlocks(cell.children);
2926
- }
2927
- }
2928
- } else if (block.type === "sdt" || block.type === "custom_xml") {
2929
- visitBlocks(block.children);
2930
- }
2931
- }
2932
- };
2933
-
2934
- visitBlocks(content.children);
2935
- return numberingInstanceIds;
2936
- }
2937
-
2938
- function collectReferencedParagraphStyleIds(
2939
- content: CanonicalDocumentEnvelope["content"],
2940
- subParts?: SubPartsCatalog,
2941
- ): Set<string> {
2942
- const styleIds = new Set<string>();
2943
-
2944
- const visitBlocks = (blocks: ReadonlyArray<BlockNode>) => {
2945
- for (const block of blocks) {
2946
- if ("styleId" in block && typeof block.styleId === "string" && block.styleId.length > 0) {
2947
- styleIds.add(block.styleId);
2948
- }
2949
- if (block.type === "table") {
2950
- for (const row of block.rows) {
2951
- for (const cell of row.cells) {
2952
- visitBlocks(cell.children);
2953
- }
2954
- }
2955
- } else if (block.type === "sdt" || block.type === "custom_xml") {
2956
- visitBlocks(block.children);
2957
- }
2958
- }
2959
- };
2960
-
2961
- visitBlocks(content.children);
2962
- if (subParts) {
2963
- for (const header of subParts.headers) {
2964
- visitBlocks(header.blocks);
2965
- }
2966
- for (const footer of subParts.footers) {
2967
- visitBlocks(footer.blocks);
2968
- }
2969
- if (subParts.footnoteCollection) {
2970
- for (const note of Object.values(subParts.footnoteCollection.footnotes)) {
2971
- visitBlocks(note.blocks);
2972
- }
2973
- for (const note of Object.values(subParts.footnoteCollection.endnotes)) {
2974
- visitBlocks(note.blocks);
2975
- }
2976
- }
2977
- }
2978
-
2979
- return styleIds;
2980
- }
2981
-
2982
- function createImportedSnapshot(input: {
2983
- documentId: string;
2984
- editorBuild: string;
2985
- timestamp: string;
2986
- document: CanonicalDocumentEnvelope;
2987
- compatibility: PersistedEditorSnapshot["compatibility"];
2988
- protectionSnapshot: ProtectionSnapshot;
2989
- sourcePackage?: PersistedEditorSnapshot["sourcePackage"];
2990
- workflowOverlay?: PersistedEditorSnapshot["workflowOverlay"];
2991
- workflowMetadata?: PersistedEditorSnapshot["workflowMetadata"];
2992
- }): PersistedEditorSnapshot {
2993
- return {
2994
- snapshotVersion: "persisted-editor-snapshot/2",
2995
- schemaVersion: input.document.schemaVersion,
2996
- documentId: input.documentId,
2997
- docId: input.document.docId,
2998
- createdAt: input.document.createdAt,
2999
- updatedAt: input.document.updatedAt,
3000
- savedAt: input.timestamp,
3001
- editorBuild: input.editorBuild,
3002
- canonicalDocument: input.document,
3003
- compatibility: input.compatibility,
3004
- warningLog: input.compatibility.warnings,
3005
- protectionSnapshot: input.protectionSnapshot,
3006
- sourcePackage: input.sourcePackage,
3007
- workflowOverlay: input.workflowOverlay,
3008
- workflowMetadata: input.workflowMetadata,
3009
- };
3010
- }
3011
-
3012
- function toPublicAnchorProjection(
3013
- anchor: InternalEditorAnchorProjection,
3014
- ): PublicEditorAnchorProjection {
3015
- switch (anchor.kind) {
3016
- case "range":
3017
- return {
3018
- kind: "range",
3019
- from: anchor.range.from,
3020
- to: anchor.range.to,
3021
- assoc: anchor.assoc,
3022
- };
3023
- case "node":
3024
- return {
3025
- kind: "node",
3026
- at: anchor.at,
3027
- assoc: anchor.assoc,
3028
- };
3029
- case "detached":
3030
- return {
3031
- kind: "detached",
3032
- lastKnownRange: anchor.lastKnownRange,
3033
- reason: anchor.reason,
3034
- };
3035
- }
3036
- }
3037
-
3038
- function toPublicCompatibilityFeatureEntry(entry: InternalCompatibilityFeatureEntry) {
3039
- return {
3040
- ...entry,
3041
- affectedAnchor: entry.affectedAnchor
3042
- ? toPublicAnchorProjection(entry.affectedAnchor)
3043
- : undefined,
3044
- };
3045
- }
3046
-
3047
- function toPublicWarning(warning: InternalEditorWarning): PublicEditorWarning {
3048
- return {
3049
- ...warning,
3050
- affectedAnchor: warning.affectedAnchor
3051
- ? toPublicAnchorProjection(warning.affectedAnchor)
3052
- : undefined,
3053
- };
3054
- }
3055
-
3056
- function toPublicError(error: InternalEditorError): EditorError {
3057
- return { ...error };
3058
- }
3059
-
3060
- function toPublicCompatibilityReport(
3061
- report: InternalCompatibilityReport,
3062
- ): PublicCompatibilityReport {
3063
- return {
3064
- reportVersion: report.reportVersion,
3065
- generatedAt: report.generatedAt,
3066
- blockExport: report.blockExport,
3067
- featureEntries: report.featureEntries.map((entry) =>
3068
- toPublicCompatibilityFeatureEntry(entry),
3069
- ),
3070
- warnings: report.warnings.map((warning) => toPublicWarning(warning)),
3071
- errors: report.errors.map((error) => toPublicError(error)),
3072
- };
3073
- }
3074
-
3075
- function createDiagnosticsSession(
3076
- options: LoadDocxEditorSessionOptions,
3077
- diagnostics: ImportDiagnosticsResult,
3078
- ): LoadedDocxEditorSession {
3079
- const timestamp = new Date().toISOString();
3080
- const editorBuild =
3081
- typeof options.editorBuild === "string" && options.editorBuild.length > 0
3082
- ? options.editorBuild
3083
- : "dev";
3084
- const runtime = createReadOnlyDiagnosticsRuntime({
3085
- documentId: options.documentId,
3086
- sourceLabel: options.sourceLabel,
3087
- editorBuild,
3088
- generatedAt: timestamp,
3089
- diagnostics,
3090
- });
3091
- const initialSnapshot = runtime.getPersistedSnapshot();
3092
- const initialSessionState = editorSessionStateFromPersistedSnapshot(initialSnapshot);
3093
-
3094
- return {
3095
- initialSessionState,
3096
- initialSnapshot,
3097
- fatalError: diagnostics.fatalError,
3098
- readOnly: true,
3099
- protectionSnapshot: EMPTY_PROTECTION_SNAPSHOT,
3100
- exportDocx: async (_sessionState, exportOptions) => runtime.exportDocx(exportOptions),
3101
- };
3102
- }
3103
-
3104
- function createImportDiagnosticsFromError(error: unknown): ImportDiagnosticsResult {
3105
- if (isPackageImportError(error)) {
3106
- return createPackageImportDiagnostics({
3107
- issue: classifyCorruptPackageError(error),
3108
- });
3109
- }
3110
-
3111
- return createValidationImportDiagnostics({
3112
- message:
3113
- error instanceof Error
3114
- ? error.message
3115
- : "DOCX import failed during validation.",
3116
- });
3117
- }
3118
-
3119
- function normalizeImportedRevisionRecords(
3120
- parsed: ParsedRevisionsResult,
3121
- content: CanonicalDocumentEnvelope["content"],
3122
- opaqueFragments: Record<string, OpaqueFragmentRecord>,
3123
- ): ParsedRevisionsResult {
3124
- const opaqueRanges = Object.values(opaqueFragments).map((fragment) => fragment.lastKnownRange);
3125
- const paragraphRanges = collectCanonicalParagraphRanges(content);
3126
- if (opaqueRanges.length === 0) {
3127
- return {
3128
- ...parsed,
3129
- revisions: parsed.revisions.map((revision) => {
3130
- if (revision.anchor.kind !== "range" || revision.metadata.preserveOnlyReason) {
3131
- return revision;
3132
- }
3133
-
3134
- const preserveOnlyReason = getStructuralPreserveOnlyReason(
3135
- revision,
3136
- paragraphRanges,
3137
- );
3138
- if (!preserveOnlyReason) {
3139
- return revision;
3140
- }
3141
-
3142
- return {
3143
- ...revision,
3144
- metadata: {
3145
- ...revision.metadata,
3146
- preserveOnlyReason,
3147
- },
3148
- };
3149
- }),
3150
- };
3151
- }
3152
-
3153
- return {
3154
- ...parsed,
3155
- revisions: parsed.revisions.map((revision) => {
3156
- const { anchor } = revision;
3157
- if (anchor.kind !== "range" || revision.metadata.preserveOnlyReason) {
3158
- return revision;
3159
- }
3160
-
3161
- const preserveOnlyReason =
3162
- getStructuralPreserveOnlyReason(revision, paragraphRanges) ??
3163
- (opaqueRanges.some((range) => rangesIntersect(range, anchor.range))
3164
- ? "Imported revision overlaps preserve-only OOXML and remains preserve-only."
3165
- : undefined);
3166
-
3167
- if (!preserveOnlyReason) {
3168
- return revision;
3169
- }
3170
-
3171
- return {
3172
- ...revision,
3173
- metadata: {
3174
- ...revision.metadata,
3175
- preserveOnlyReason,
3176
- },
3177
- };
3178
- }),
3179
- };
3180
- }
3181
-
3182
- export function normalizeImportedCommentThreads(
3183
- parsed: ParsedCommentsResult,
3184
- opaqueFragments: Record<string, OpaqueFragmentRecord>,
3185
- revisions: readonly ReviewRevisionRecord[],
3186
- ): NormalizedImportedCommentsResult {
3187
- const opaqueRanges = Object.values(opaqueFragments).map((fragment) => fragment.lastKnownRange);
3188
- // Use getRevisionActionability() — not the raw preserveOnlyReason flag — so
3189
- // Lane 7b-promoted shapes (cellIns/cellDel/cellMerge, linked-pair moves) that
3190
- // still carry a default preserveOnlyReason aren't treated as preserve-only
3191
- // for comment-anchor detachment. See revision-types.ts:192-200.
3192
- const preserveOnlyRevisionRanges = revisions.flatMap((revision) => {
3193
- if (
3194
- revision.anchor.kind !== "range" ||
3195
- getRevisionActionability(revision) !== "preserve-only"
3196
- ) {
3197
- return [];
3198
- }
3199
- return [revision.anchor.range];
3200
- });
3201
- const preserveOnlyCommentIds = new Set(parsed.diagnostics.map((diagnostic) => diagnostic.commentId));
3202
- const additionalDiagnostics: CommentImportDiagnostic[] = [];
3203
- const normalizedThreads: CommentThread[] = parsed.threads.map((thread) => {
3204
- const { anchor } = thread;
3205
- if (anchor.kind !== "range") {
3206
- preserveOnlyCommentIds.add(thread.commentId);
3207
- return thread;
3208
- }
3209
-
3210
- const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, anchor.range));
3211
- if (opaqueOverlap) {
3212
- preserveOnlyCommentIds.add(thread.commentId);
3213
- additionalDiagnostics.push({
3214
- commentId: thread.commentId,
3215
- code: "opaque_anchor_preserve_only",
3216
- message:
3217
- "Comment anchor intersects preserve-only OOXML content. Thread is visible but detached; anchor cannot be safely remapped.",
3218
- featureClass: "preserve-only",
3219
- detachedReason: "opaque-region" as const,
3220
- actionabilityNote: "The comment body is preserved. The anchor overlaps opaque content that the editor cannot safely modify.",
3221
- });
3222
- return {
3223
- ...thread,
3224
- anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
3225
- status: "detached",
3226
- metadata: {
3227
- ...thread.metadata,
3228
- detachedReason: "opaque-region",
3229
- actionabilityNote:
3230
- "The comment body is preserved. The anchor overlaps opaque content that the editor cannot safely modify.",
3231
- },
3232
- };
3233
- }
3234
-
3235
- const preserveOnlyRevisionOverlap = preserveOnlyRevisionRanges.some((range) =>
3236
- rangesIntersect(range, anchor.range),
3237
- );
3238
- if (preserveOnlyRevisionOverlap) {
3239
- preserveOnlyCommentIds.add(thread.commentId);
3240
- additionalDiagnostics.push({
3241
- commentId: thread.commentId,
3242
- code: "preserve_only_revision_overlap",
3243
- message:
3244
- "Comment anchor overlaps preserve-only review markup. Thread is visible but detached; anchor cannot be safely remapped during editing.",
3245
- featureClass: "preserve-only",
3246
- detachedReason: "revision-overlap" as const,
3247
- actionabilityNote: "The comment body is preserved. The anchor overlaps preserve-only revision markup that the editor cannot safely modify.",
3248
- });
3249
- return {
3250
- ...thread,
3251
- anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
3252
- status: "detached",
3253
- metadata: {
3254
- ...thread.metadata,
3255
- detachedReason: "revision-overlap",
3256
- actionabilityNote:
3257
- "The comment body is preserved. The anchor overlaps preserve-only revision markup that the editor cannot safely modify.",
3258
- },
3259
- };
3260
- }
3261
-
3262
- return thread;
3263
- });
3264
-
3265
- return {
3266
- ...parsed,
3267
- threads: normalizedThreads,
3268
- diagnostics: [...parsed.diagnostics, ...additionalDiagnostics],
3269
- definitions: parsed.definitions,
3270
- preservedDefinitions: parsed.definitions.filter((definition) =>
3271
- preserveOnlyCommentIds.has(
3272
- resolveDefinitionRootCommentId(definition, parsed.definitions),
3273
- ),
3274
- ),
3275
- };
3276
- }
3277
-
3278
- function translateClmCommentsToWorkflow(input: {
3279
- comments: readonly CommentThread[];
3280
- workflowOverlay?: WorkflowOverlay;
3281
- workflowMetadata?: WorkflowMetadataSnapshot;
3282
- timestamp: string;
3283
- }): {
3284
- comments: CommentThread[];
3285
- workflowOverlay?: WorkflowOverlay;
3286
- workflowMetadata?: WorkflowMetadataSnapshot;
3287
- } {
3288
- const nextComments = input.comments.map((thread) => structuredClone(thread));
3289
- let nextOverlay = input.workflowOverlay ? structuredClone(input.workflowOverlay) : undefined;
3290
- let overlayChanged = false;
3291
-
3292
- for (const [index, thread] of nextComments.entries()) {
3293
- const directive = parseClmCommentDirective(thread);
3294
- if (!directive || thread.anchor.kind !== "range") {
3295
- continue;
3296
- }
3297
-
3298
- if (!nextOverlay) {
3299
- nextOverlay = {
3300
- overlayVersion: "workflow-overlay/1",
3301
- scopes: [],
3302
- };
3303
- }
3304
-
3305
- const existingScope = findExistingClmScope(nextOverlay.scopes, directive);
3306
- if (!existingScope) {
3307
- const version = getNextClmScopeVersion(nextOverlay.scopes, thread.anchor);
3308
- const scopeId = `clm-scope-${thread.commentId}-v${version}`;
3309
- const workItem = directive.tag === "TASK"
3310
- ? createClmWorkflowWorkItem(thread, directive.description, scopeId)
3311
- : undefined;
3312
- const scope = createClmWorkflowScope(thread, directive, version, scopeId, workItem?.workItemId);
3313
-
3314
- nextOverlay.scopes = [...nextOverlay.scopes, scope];
3315
- if (workItem) {
3316
- nextOverlay.workItems = [...(nextOverlay.workItems ?? []), workItem];
3317
- if (!nextOverlay.activeWorkItemId) {
3318
- nextOverlay.activeWorkItemId = workItem.workItemId;
3319
- }
3320
- }
3321
- overlayChanged = true;
3322
- }
3323
-
3324
- nextComments[index] = resolveImportedCommentThreadOnTranslation(thread, input.timestamp);
3325
- }
3326
-
3327
- return {
3328
- comments: nextComments,
3329
- workflowOverlay: overlayChanged || input.workflowOverlay ? nextOverlay : undefined,
3330
- workflowMetadata: input.workflowMetadata,
3331
- };
3332
- }
3333
-
3334
- function parseClmCommentDirective(
3335
- thread: CommentThread,
3336
- ): { tag: "TASK" | "READ" | "COMMENT" | "EDIT"; description: string; sourceCommentId: string; sourceCommentDurableId?: string; sourceCommentParaId?: string; mode: WorkflowScope["mode"] } | undefined {
3337
- if (thread.metadata?.rootParaId === undefined && thread.entries[0]?.metadata?.parentParaId) {
3338
- return undefined;
3339
- }
3340
- const firstMeaningfulLine = thread.entries
3341
- .flatMap((entry) => entry.body.split(/\r?\n/u))
3342
- .map((line) => line.trim())
3343
- .find((line) => line.length > 0);
3344
- if (!firstMeaningfulLine) {
3345
- return undefined;
3346
- }
3347
- const match = /^CLM:([A-Z]+):(.*)$/u.exec(firstMeaningfulLine);
3348
- if (!match) {
3349
- return undefined;
3350
- }
3351
- const tag = match[1] as "TASK" | "READ" | "COMMENT" | "EDIT";
3352
- const description = (match[2] ?? "").trim();
3353
- const mode = getClmWorkflowScopeMode(tag);
3354
- if (!mode || description.length === 0) {
3355
- return undefined;
3356
- }
3357
- return {
3358
- tag,
3359
- description,
3360
- sourceCommentId: thread.metadata?.rootOoxmlCommentId ?? thread.commentId,
3361
- sourceCommentDurableId: thread.entries[0]?.metadata?.durableId,
3362
- sourceCommentParaId: thread.metadata?.rootParaId ?? thread.entries[0]?.metadata?.paraId,
3363
- mode,
3364
- };
3365
- }
3366
-
3367
- function getClmWorkflowScopeMode(tag: string): WorkflowScope["mode"] | undefined {
3368
- switch (tag) {
3369
- case "TASK":
3370
- return "suggest";
3371
- case "READ":
3372
- return "view";
3373
- case "COMMENT":
3374
- return "comment";
3375
- case "EDIT":
3376
- return "edit";
3377
- default:
3378
- return undefined;
3379
- }
3380
- }
3381
-
3382
- function createClmWorkflowWorkItem(
3383
- thread: CommentThread,
3384
- description: string,
3385
- scopeId: string,
3386
- ): WorkflowWorkItem {
3387
- return {
3388
- workItemId: `clm-task-${thread.commentId}`,
3389
- title: description,
3390
- description,
3391
- status: "pending",
3392
- scopeIds: [scopeId],
3393
- };
3394
- }
3395
-
3396
- function createClmWorkflowScope(
3397
- thread: CommentThread,
3398
- directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
3399
- version: number,
3400
- scopeId: string,
3401
- workItemId?: string,
3402
- ): WorkflowScope {
3403
- if (workItemId) {
3404
- return {
3405
- scopeId,
3406
- version,
3407
- mode: directive.mode,
3408
- anchor: toPublicAnchorProjection(thread.anchor),
3409
- storyTarget: { kind: "main" },
3410
- workItemId,
3411
- label: directive.description,
3412
- metadata: createClmScopeMetadata(directive),
3413
- };
3414
- }
3415
- return {
3416
- scopeId,
3417
- version,
3418
- mode: directive.mode,
3419
- anchor: toPublicAnchorProjection(thread.anchor),
3420
- storyTarget: { kind: "main" },
3421
- label: directive.description,
3422
- metadata: createClmScopeMetadata(directive),
3423
- };
3424
- }
3425
-
3426
- function createClmScopeMetadata(
3427
- directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
3428
- ): WorkflowScopeMetadataField[] {
3429
- return [
3430
- {
3431
- key: "workblock.clm.tag",
3432
- valueType: "string",
3433
- value: directive.tag,
3434
- },
3435
- {
3436
- key: "workblock.clm.description",
3437
- valueType: "string",
3438
- value: directive.description,
3439
- },
3440
- {
3441
- key: "workblock.sourceCommentId",
3442
- valueType: "string",
3443
- value: directive.sourceCommentId,
3444
- },
3445
- ...(directive.sourceCommentDurableId
3446
- ? [{
3447
- key: "workblock.sourceCommentDurableId",
3448
- valueType: "string" as const,
3449
- value: directive.sourceCommentDurableId,
3450
- }]
3451
- : []),
3452
- ...(directive.sourceCommentParaId
3453
- ? [{
3454
- key: "workblock.sourceCommentParaId",
3455
- valueType: "string" as const,
3456
- value: directive.sourceCommentParaId,
3457
- }]
3458
- : []),
3459
- ];
3460
- }
3461
-
3462
- function findExistingClmScope(
3463
- scopes: readonly WorkflowScope[],
3464
- directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
3465
- ): WorkflowScope | undefined {
3466
- return scopes.find((scope) => {
3467
- const sourceCommentId = getWorkflowScopeMetadataValue(scope.metadata, "workblock.sourceCommentId");
3468
- const sourceCommentDurableId = getWorkflowScopeMetadataValue(
3469
- scope.metadata,
3470
- "workblock.sourceCommentDurableId",
3471
- );
3472
- return (
3473
- sourceCommentId === directive.sourceCommentId ||
3474
- (directive.sourceCommentDurableId !== undefined &&
3475
- sourceCommentDurableId === directive.sourceCommentDurableId)
3476
- );
3477
- });
3478
- }
3479
-
3480
- function getWorkflowScopeMetadataValue(
3481
- metadata: WorkflowScope["metadata"] | undefined,
3482
- key: string,
3483
- ): string | undefined {
3484
- const field = metadata?.find((entry) => entry.key === key);
3485
- return typeof field?.value === "string" ? field.value : undefined;
3486
- }
3487
-
3488
- function getNextClmScopeVersion(
3489
- scopes: readonly WorkflowScope[],
3490
- anchor: Extract<CommentThread["anchor"], { kind: "range" }>,
3491
- ): number {
3492
- const anchorRange = {
3493
- from: anchor.range.from,
3494
- to: anchor.range.to,
3495
- };
3496
- const overlappingVersions = scopes.flatMap((scope) => {
3497
- if (scope.anchor.kind !== "range") {
3498
- return [];
3499
- }
3500
- return rangesIntersect(scope.anchor, anchorRange) && typeof scope.version === "number"
3501
- ? [scope.version]
3502
- : [];
3503
- });
3504
- return overlappingVersions.length > 0 ? Math.max(...overlappingVersions) + 1 : 1;
3505
- }
3506
-
3507
- function resolveImportedCommentThreadOnTranslation(
3508
- thread: CommentThread,
3509
- timestamp: string,
3510
- ): CommentThread {
3511
- if (thread.status === "resolved") {
3512
- return thread;
3513
- }
3514
- return {
3515
- ...thread,
3516
- status: thread.status === "detached" ? "detached" : "resolved",
3517
- resolution: thread.resolution ?? {
3518
- resolvedAt: timestamp,
3519
- resolvedBy: thread.entries[thread.entries.length - 1]?.authorId ?? thread.createdBy,
3520
- },
3521
- };
3522
- }
3523
-
3524
- function resolveDefinitionRootCommentId(
3525
- definition: ImportedCommentDefinition,
3526
- definitions: readonly ImportedCommentDefinition[],
3527
- ): string {
3528
- if (!definition.parentParaId) {
3529
- return definition.commentId;
3530
- }
3531
-
3532
- const definitionsByParaId = new Map(
3533
- definitions
3534
- .filter((candidate) => typeof candidate.paraId === "string")
3535
- .map((candidate) => [candidate.paraId!, candidate]),
3536
- );
3537
- const visited = new Set<string>();
3538
- let current: ImportedCommentDefinition | undefined = definition;
3539
-
3540
- while (current?.parentParaId) {
3541
- if (visited.has(current.parentParaId)) {
3542
- break;
3543
- }
3544
- visited.add(current.parentParaId);
3545
- const parent = definitionsByParaId.get(current.parentParaId);
3546
- if (!parent) {
3547
- break;
3548
- }
3549
- current = parent;
3550
- }
3551
-
3552
- return current?.commentId ?? definition.commentId;
3553
- }
3554
-
3555
- function getStructuralPreserveOnlyReason(
3556
- revision: ReviewRevisionRecord,
3557
- paragraphRanges: ReadonlyArray<{ start: number; end: number }>,
3558
- ): string | undefined {
3559
- const form = revision.metadata.importedRevisionForm;
3560
- const { anchor } = revision;
3561
- if (!form || anchor.kind !== "range") {
3562
- return undefined;
3563
- }
3564
-
3565
- if (
3566
- (form === "run-insertion" || form === "run-deletion") &&
3567
- anchor.range.from === anchor.range.to
3568
- ) {
3569
- return "Imported zero-width run revision remains preserve-only.";
3570
- }
3571
-
3572
- if (form === "paragraph-insertion" || form === "paragraph-deletion") {
3573
- const paragraphBoundary = paragraphRanges.find(
3574
- (boundary) =>
3575
- boundary.end === anchor.range.from ||
3576
- (anchor.range.from >= boundary.start &&
3577
- anchor.range.from <= boundary.end),
3578
- );
3579
- return paragraphBoundary
3580
- ? undefined
3581
- : "Imported revision spans paragraph-level structure and remains preserve-only.";
3582
- }
3583
-
3584
- const paragraphBoundary = paragraphRanges.find(
3585
- (boundary) =>
3586
- anchor.range.from >= boundary.start &&
3587
- anchor.range.to <= boundary.end,
3588
- );
3589
- return paragraphBoundary
3590
- ? undefined
3591
- : "Imported revision spans structural boundaries and remains preserve-only.";
3592
- }
3593
-
3594
- function collectCanonicalParagraphRanges(
3595
- content: CanonicalDocumentEnvelope["content"],
3596
- ): Array<{ start: number; end: number }> {
3597
- const ranges: Array<{ start: number; end: number }> = [];
3598
- let cursor = 0;
3599
- let previousWasParagraph = false;
3600
-
3601
- for (const block of content.children) {
3602
- if (block.type === "paragraph") {
3603
- if (previousWasParagraph) {
3604
- cursor += 1;
3605
- }
3606
- const start = cursor;
3607
- cursor += measureCanonicalParagraph(block);
3608
- ranges.push({ start, end: cursor });
3609
- previousWasParagraph = true;
3610
- continue;
3611
- }
3612
-
3613
- cursor += 1;
3614
- previousWasParagraph = false;
3615
- }
3616
-
3617
- return ranges;
3618
- }
3619
-
3620
- function measureCanonicalParagraph(paragraph: CanonicalDocumentEnvelope["content"]["children"][number] & { type: "paragraph" }): number {
3621
- return paragraph.children.reduce<number>((size, child) => {
3622
- if (child.type === "text") {
3623
- return size + Array.from(child.text).length;
3624
- }
3625
- if (child.type === "hyperlink") {
3626
- return (
3627
- size +
3628
- child.children.reduce<number>((childSize, entry) => {
3629
- if (entry.type === "text") {
3630
- return childSize + Array.from(entry.text).length;
3631
- }
3632
- return childSize + 1;
3633
- }, 0)
3634
- );
3635
- }
3636
- return size + 1;
3637
- }, 0);
3638
- }
3639
-
3640
- function rangesIntersect(
3641
- left: { from: number; to: number },
3642
- right: { from: number; to: number },
3643
- ): boolean {
3644
- return left.from < right.to && right.from < left.to;
3645
- }
3646
-
3647
- function resolveCommentsPartPath(
3648
- sourcePackage: OpcPackage,
3649
- sourceDocumentPartPath: string,
3650
- relationships: readonly OpcRelationship[],
3651
- ): string | undefined {
3652
- return resolveDocumentRelatedPartPath(
3653
- sourcePackage,
3654
- sourceDocumentPartPath,
3655
- relationships,
3656
- COMMENTS_RELATIONSHIP_TYPE,
3657
- COMMENTS_PART_PATH,
3658
- );
3659
- }
3660
-
3661
- function resolveDocumentRelatedPartPath(
3662
- sourcePackage: OpcPackage,
3663
- sourceDocumentPartPath: string,
3664
- relationships: readonly OpcRelationship[],
3665
- relationshipType: string,
3666
- fallbackPartPath: string,
3667
- ): string | undefined {
3668
- const relationship = relationships.find(
3669
- (candidate) =>
3670
- candidate.type === relationshipType &&
3671
- candidate.targetMode === "internal",
3672
- );
3673
- if (!relationship) {
3674
- return sourcePackage.parts.has(fallbackPartPath) ? fallbackPartPath : undefined;
3675
- }
3676
-
3677
- const targetPath = resolveRelationshipTarget(sourceDocumentPartPath, relationship);
3678
- return sourcePackage.parts.has(targetPath) ? targetPath : undefined;
3679
- }
3680
-
3681
- function resolveMainDocumentPartPath(sourcePackage: OpcPackage): string | undefined {
3682
- const relationship = sourcePackage.manifest.packageRelationships.find(
3683
- (candidate) =>
3684
- candidate.type === OFFICE_DOCUMENT_RELATIONSHIP_TYPE &&
3685
- candidate.targetMode === "internal",
3686
- );
3687
- if (relationship) {
3688
- return resolveRelationshipTarget(null, relationship);
3689
- }
3690
-
3691
- return sourcePackage.parts.has(MAIN_DOCUMENT_PATH) ? MAIN_DOCUMENT_PATH : undefined;
3692
- }
3693
-
3694
- function toRuntimeCommentRecords(
3695
- threads: readonly CommentThread[],
3696
- ): Record<string, CommentThreadRecord> {
3697
- return Object.fromEntries(
3698
- threads.map((thread) => {
3699
- return [
3700
- thread.commentId,
3701
- {
3702
- commentId: thread.commentId,
3703
- body: thread.entries.map((entry) => entry.body).join("\n"),
3704
- anchor: thread.anchor,
3705
- createdBy: thread.createdBy,
3706
- authorId: thread.createdBy,
3707
- createdAt: thread.createdAt,
3708
- entries: thread.entries.map((entry) => ({
3709
- entryId: entry.entryId,
3710
- authorId: entry.authorId,
3711
- body: entry.body,
3712
- createdAt: entry.createdAt,
3713
- metadata: entry.metadata
3714
- ? {
3715
- ooxmlCommentId: entry.metadata.ooxmlCommentId,
3716
- paraId: entry.metadata.paraId,
3717
- parentParaId: entry.metadata.parentParaId,
3718
- durableId: entry.metadata.durableId,
3719
- initials: entry.metadata.initials,
3720
- }
3721
- : undefined,
3722
- })),
3723
- status: thread.status,
3724
- resolution: thread.resolution
3725
- ? {
3726
- resolvedAt: thread.resolution.resolvedAt,
3727
- resolvedBy: thread.resolution.resolvedBy,
3728
- }
3729
- : undefined,
3730
- resolvedAt: thread.resolution?.resolvedAt,
3731
- warningIds: [...thread.warningIds],
3732
- isResolved: thread.status === "resolved",
3733
- metadata: thread.metadata
3734
- ? {
3735
- source: thread.metadata.source,
3736
- rootOoxmlCommentId: thread.metadata.rootOoxmlCommentId,
3737
- rootParaId: thread.metadata.rootParaId,
3738
- }
3739
- : undefined,
3740
- } satisfies CommentThreadRecord,
3741
- ];
3742
- }),
3743
- );
3744
- }
3745
-
3746
- function toRuntimeRevisionRecords(
3747
- revisions: readonly ReviewRevisionRecord[],
3748
- ): Record<string, RuntimeRevisionRecord> {
3749
- return Object.fromEntries(
3750
- revisions.map((revision) => [
3751
- revision.revisionId,
3752
- {
3753
- changeId: revision.revisionId,
3754
- kind: revision.kind,
3755
- anchor: revision.anchor,
3756
- authorId: revision.authorId,
3757
- createdAt: revision.createdAt,
3758
- warningIds: [...revision.warningIds],
3759
- metadata: {
3760
- source: revision.metadata.source,
3761
- storyTarget: revision.metadata.storyTarget,
3762
- preserveOnlyReason: revision.metadata.preserveOnlyReason,
3763
- suggestionId: revision.metadata.suggestionId,
3764
- semanticKind: revision.metadata.semanticKind,
3765
- linkedRevisionIds: revision.metadata.linkedRevisionIds,
3766
- predecessorSuggestionId: revision.metadata.predecessorSuggestionId,
3767
- importedRevisionForm: revision.metadata.importedRevisionForm,
3768
- originalRevisionType: revision.metadata.originalRevisionType,
3769
- ooxmlRevisionId: revision.metadata.ooxmlRevisionId,
3770
- propertyChangeData: revision.metadata.propertyChangeData,
3771
- moveData: revision.metadata.moveData,
3772
- },
3773
- status: revision.status === "active" ? "open" : revision.status,
3774
- } satisfies RuntimeRevisionRecord,
3775
- ]),
3776
- );
3777
- }
3778
-
3779
- function toReviewRevisionRecords(
3780
- revisions: CanonicalDocumentEnvelope["review"]["revisions"],
3781
- ): ReviewRevisionRecord[] {
3782
- return Object.values(revisions).map((revision) => ({
3783
- revisionId: revision.changeId,
3784
- kind: revision.kind,
3785
- anchor: revision.anchor,
3786
- authorId: revision.authorId ?? "unknown",
3787
- createdAt: revision.createdAt,
3788
- warningIds: [...(revision.warningIds ?? [])],
3789
- metadata: {
3790
- source: revision.metadata?.source ?? "runtime",
3791
- storyTarget: revision.metadata?.storyTarget,
3792
- preserveOnlyReason: revision.metadata?.preserveOnlyReason,
3793
- suggestionId: revision.metadata?.suggestionId,
3794
- semanticKind: revision.metadata?.semanticKind,
3795
- linkedRevisionIds: revision.metadata?.linkedRevisionIds,
3796
- predecessorSuggestionId: revision.metadata?.predecessorSuggestionId,
3797
- importedRevisionForm: revision.metadata?.importedRevisionForm,
3798
- originalRevisionType: revision.metadata?.originalRevisionType,
3799
- ooxmlRevisionId: revision.metadata?.ooxmlRevisionId,
3800
- propertyChangeData: revision.metadata?.propertyChangeData,
3801
- moveData: revision.metadata?.moveData,
3802
- },
3803
- status: revision.status === "open" ? "active" : revision.status,
3804
- }));
3805
- }
3806
-
3807
- function serializeSecondaryStoryWithRuntimeRevisions(
3808
- xml: string,
3809
- revisions: readonly ReviewRevisionRecord[],
3810
- label: string,
3811
- ): string {
3812
- if (revisions.length === 0) {
3813
- return xml;
3814
- }
3815
-
3816
- const serialized = serializeRuntimeRevisionsIntoStoryXml(xml, revisions);
3817
- if (serialized.skippedRevisionIds.length > 0) {
3818
- throw new Error(
3819
- `DOCX export is blocked because ${serialized.skippedRevisionIds.length} active revisions overlap unsupported serialization boundaries in ${label}.`,
3820
- );
3821
- }
3822
- return serialized.documentXml;
3823
- }
3824
-
3825
- export function stripCommentMarkup(
3826
- documentXml: string,
3827
- ownedCommentIds: readonly string[],
3828
- ): string {
3829
- if (ownedCommentIds.length === 0) {
3830
- return documentXml;
3831
- }
3832
-
3833
- let output = documentXml;
3834
- for (const commentId of ownedCommentIds) {
3835
- const escapedCommentId = escapeRegExp(commentId);
3836
- const attributePattern = `(?:w:id|id)=["']${escapedCommentId}["']`;
3837
- output = output
3838
- .replace(
3839
- new RegExp(`<w:commentRangeStart\\b[^>]*${attributePattern}[^>]*/>`, "gu"),
3840
- "",
3841
- )
3842
- .replace(
3843
- new RegExp(`<w:commentRangeEnd\\b[^>]*${attributePattern}[^>]*/>`, "gu"),
3844
- "",
3845
- )
3846
- .replace(
3847
- new RegExp(
3848
- `<w:r\\b[^>]*>(?:(?!<w:t\\b|</w:r>)[\\s\\S])*?<w:commentReference\\b[^>]*${attributePattern}[^>]*/>(?:(?!<w:t\\b|</w:r>)[\\s\\S])*?</w:r>`,
3849
- "gu",
3850
- ),
3851
- "",
3852
- );
3853
- }
3854
-
3855
- return output;
3856
- }
3857
-
3858
- function escapeRegExp(value: string): string {
3859
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3860
- }
3861
-
3862
- function withDocumentRelatedParts(
3863
- relationships: readonly OpcRelationship[],
3864
- relatedParts: ReadonlyArray<{
3865
- relationshipType: string;
3866
- partPath: string;
3867
- existingRelationshipId?: string;
3868
- include: boolean;
3869
- }>,
3870
- ): OpcRelationship[] {
3871
- const filteredTypes = new Set(relatedParts.map((part) => part.relationshipType));
3872
- const nextRelationships = relationships
3873
- .filter((relationship) => !filteredTypes.has(relationship.type))
3874
- .map(cloneRelationship);
3875
-
3876
- for (const part of relatedParts) {
3877
- if (!part.include) {
3878
- continue;
3879
- }
3880
-
3881
- nextRelationships.push({
3882
- id: part.existingRelationshipId ?? createCommentsRelationshipId(nextRelationships),
3883
- type: part.relationshipType,
3884
- target: toDocumentRelativeTarget(part.partPath),
3885
- targetMode: "internal",
3886
- });
3887
- }
3888
-
3889
- return nextRelationships;
3890
- }
3891
-
3892
- function collectPreservedPackageParts(
3893
- sourcePackage: OpcPackage,
3894
- ownedPartPaths: ReadonlyArray<string | undefined>,
3895
- ): Record<string, PreservedPackagePart> {
3896
- const normalizedOwnedPaths = new Set(
3897
- ownedPartPaths
3898
- .filter((value): value is string => typeof value === "string")
3899
- .map((path) => normalizePartPath(path)),
3900
- );
3901
- return Object.fromEntries(
3902
- [...sourcePackage.parts.values()]
3903
- .filter((part) =>
3904
- shouldPreservePackagePart(part.path, part.surfaceKind, normalizedOwnedPaths),
3905
- )
3906
- .map((part) => [
3907
- part.path,
3908
- {
3909
- packagePartName: part.path,
3910
- contentType: part.contentType ?? "application/octet-stream",
3911
- relationshipIds: findRelationshipIdsTargetingPart(sourcePackage, part.path),
3912
- } satisfies PreservedPackagePart,
3913
- ]),
3914
- );
3915
- }
3916
-
3917
- function collectInlineMediaParts(
3918
- sourcePackage: OpcPackage,
3919
- ): ReadonlyMap<string, { path: string; contentType: string }> {
3920
- return new Map(
3921
- [...sourcePackage.parts.values()]
3922
- .filter(
3923
- (part) =>
3924
- part.path.startsWith("/word/media/") && typeof part.contentType === "string",
3925
- )
3926
- .map((part) => [
3927
- part.path,
3928
- {
3929
- path: part.path,
3930
- contentType: part.contentType ?? "application/octet-stream",
3931
- },
3932
- ]),
3933
- );
3934
- }
3935
-
3936
- function createEmptyNumberingCatalog(): NumberingCatalog {
3937
- return {
3938
- abstractDefinitions: {},
3939
- instances: {},
3940
- };
3941
- }
3942
-
3943
- function collectBrokenInternalRelationshipIssues(
3944
- sourcePackage: OpcPackage,
3945
- mainDocumentPath?: string,
3946
- ): ReturnType<typeof createBrokenRelationshipIssue>[] {
3947
- const brokenTargets = new Map<string, ReturnType<typeof createBrokenRelationshipIssue>>();
3948
-
3949
- for (const relationship of sourcePackage.manifest.packageRelationships) {
3950
- if (relationship.targetMode !== "internal") {
3951
- continue;
3952
- }
3953
-
3954
- const target = resolveRelationshipTarget(null, relationship);
3955
- if (
3956
- !sourcePackage.parts.has(target) &&
3957
- isFatalBrokenRelationship({
3958
- relationshipSourcePath: null,
3959
- relationship,
3960
- targetPartPath: target,
3961
- mainDocumentPath,
3962
- })
3963
- ) {
3964
- brokenTargets.set(
3965
- `package:${relationship.id}:${target}`,
3966
- createBrokenRelationshipIssue({
3967
- relationshipSourcePath: null,
3968
- relationshipId: relationship.id,
3969
- targetPartPath: target,
3970
- }),
3971
- );
3972
- }
3973
- }
3974
-
3975
- for (const part of sourcePackage.parts.values()) {
3976
- for (const relationship of part.relationships) {
3977
- if (relationship.targetMode !== "internal") {
3978
- continue;
3979
- }
3980
-
3981
- const target = resolveRelationshipTarget(part.path, relationship);
3982
- if (
3983
- !sourcePackage.parts.has(target) &&
3984
- isFatalBrokenRelationship({
3985
- relationshipSourcePath: part.path,
3986
- relationship,
3987
- targetPartPath: target,
3988
- mainDocumentPath,
3989
- })
3990
- ) {
3991
- brokenTargets.set(
3992
- `${part.path}:${relationship.id}:${target}`,
3993
- createBrokenRelationshipIssue({
3994
- relationshipSourcePath: part.path,
3995
- relationshipId: relationship.id,
3996
- targetPartPath: target,
3997
- }),
3998
- );
3999
- }
4000
- }
4001
- }
4002
-
4003
- return [...brokenTargets.values()].sort((left, right) =>
4004
- `${left.relationshipSourcePath ?? ""}:${left.relationshipId}:${left.targetPartPath}`.localeCompare(
4005
- `${right.relationshipSourcePath ?? ""}:${right.relationshipId}:${right.targetPartPath}`,
4006
- ),
4007
- );
4008
- }
4009
-
4010
- function summarizeBrokenRelationshipIssues(
4011
- issues: readonly ReturnType<typeof createBrokenRelationshipIssue>[],
4012
- ): string {
4013
- return `DOCX package has unresolved internal relationships: ${issues
4014
- .map((issue) => issue.targetPartPath ?? issue.message)
4015
- .slice(0, 3)
4016
- .join(", ")}${issues.length > 3 ? ", ..." : ""}.`;
4017
- }
4018
-
4019
- function createBrokenRelationshipWarnings(
4020
- sourcePackage: OpcPackage,
4021
- mainDocumentPath?: string,
4022
- ): CanonicalDocumentEnvelope["diagnostics"]["warnings"] {
4023
- const warnings = new Map<string, CanonicalDocumentEnvelope["diagnostics"]["warnings"][number]>();
4024
-
4025
- for (const relationship of sourcePackage.manifest.packageRelationships) {
4026
- if (relationship.targetMode !== "internal") {
4027
- continue;
4028
- }
4029
-
4030
- const target = resolveRelationshipTarget(null, relationship);
4031
- if (
4032
- sourcePackage.parts.has(target) ||
4033
- isFatalBrokenRelationship({
4034
- relationshipSourcePath: null,
4035
- relationship,
4036
- targetPartPath: target,
4037
- mainDocumentPath,
4038
- })
4039
- ) {
4040
- continue;
4041
- }
4042
-
4043
- warnings.set(`package:${relationship.id}:${target}`, {
4044
- diagnosticId: `diagnostic:broken-relationship-package-${relationship.id}`,
4045
- warningId: `warning:broken-relationship:${relationship.id}`,
4046
- source: "preservation",
4047
- message: `DOCX package has unresolved internal relationships outside the editor-owned graph: ${target}.`,
4048
- });
4049
- }
4050
-
4051
- for (const part of sourcePackage.parts.values()) {
4052
- for (const relationship of part.relationships) {
4053
- if (relationship.targetMode !== "internal") {
4054
- continue;
4055
- }
4056
-
4057
- const target = resolveRelationshipTarget(part.path, relationship);
4058
- if (
4059
- sourcePackage.parts.has(target) ||
4060
- isFatalBrokenRelationship({
4061
- relationshipSourcePath: part.path,
4062
- relationship,
4063
- targetPartPath: target,
4064
- mainDocumentPath,
4065
- })
4066
- ) {
4067
- continue;
4068
- }
4069
-
4070
- warnings.set(`${part.path}:${relationship.id}:${target}`, {
4071
- diagnosticId: `diagnostic:broken-relationship-${relationship.id}`,
4072
- warningId: `warning:broken-relationship:${relationship.id}`,
4073
- source: "preservation",
4074
- message: `DOCX package has unresolved internal relationships outside the editor-owned graph: ${target}.`,
4075
- });
4076
- }
4077
- }
4078
-
4079
- return [...warnings.values()];
4080
- }
4081
-
4082
- function isFatalBrokenRelationship(input: {
4083
- relationshipSourcePath: string | null;
4084
- relationship: OpcRelationship;
4085
- targetPartPath: string;
4086
- mainDocumentPath?: string;
4087
- }): boolean {
4088
- return (
4089
- input.relationshipSourcePath === null &&
4090
- input.relationship.type === OFFICE_DOCUMENT_RELATIONSHIP_TYPE &&
4091
- input.targetPartPath === input.mainDocumentPath
4092
- );
4093
- }
4094
-
4095
- function shouldPreservePackagePart(
4096
- partPath: string,
4097
- surfaceKind: OpcPackage["parts"] extends Map<string, infer T>
4098
- ? T extends { surfaceKind: infer U }
4099
- ? U
4100
- : never
4101
- : never,
4102
- ownedPartPaths: ReadonlySet<string>,
4103
- ): boolean {
4104
- if (surfaceKind !== "content") {
4105
- return false;
4106
- }
4107
-
4108
- if (
4109
- ownedPartPaths.has(partPath) ||
4110
- partPath.startsWith("/word/media/") ||
4111
- CORE_NON_PRESERVED_PART_PATHS.has(partPath)
4112
- ) {
4113
- return false;
4114
- }
4115
-
4116
- return true;
4117
- }
4118
-
4119
- function findRelationshipIdsTargetingPart(
4120
- sourcePackage: OpcPackage,
4121
- targetPartPath: string,
4122
- ): string[] {
4123
- const ids = new Set<string>();
4124
-
4125
- for (const relationship of sourcePackage.manifest.packageRelationships) {
4126
- if (
4127
- relationship.targetMode === "internal" &&
4128
- resolveRelationshipTarget(null, relationship) === targetPartPath
4129
- ) {
4130
- ids.add(relationship.id);
4131
- }
4132
- }
4133
-
4134
- for (const part of sourcePackage.parts.values()) {
4135
- for (const relationship of part.relationships) {
4136
- if (
4137
- relationship.targetMode === "internal" &&
4138
- resolveRelationshipTarget(part.path, relationship) === targetPartPath
4139
- ) {
4140
- ids.add(relationship.id);
4141
- }
4142
- }
4143
- }
4144
-
4145
- return [...ids].sort();
4146
- }
4147
-
4148
- function extractDocumentRootAttributes(documentXml: string): Record<string, string> {
4149
- const match = documentXml.match(
4150
- /<(?:[A-Za-z_][A-Za-z0-9:._-]*:)?document\b([^>]*)>/u,
4151
- );
4152
- if (!match) {
4153
- return {};
4154
- }
4155
-
4156
- const attributes: Record<string, string> = {};
4157
- const pattern = /([A-Za-z_][A-Za-z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/gu;
4158
- for (const attributeMatch of (match[1] ?? "").matchAll(pattern)) {
4159
- const name = attributeMatch[1];
4160
- const value = attributeMatch[3] ?? attributeMatch[4] ?? "";
4161
- if (!name) {
4162
- continue;
4163
- }
4164
- attributes[name] = value;
4165
- }
4166
-
4167
- return attributes;
4168
- }
4169
-
4170
- function isPackageImportError(error: unknown): boolean {
4171
- if (!(error instanceof Error)) {
4172
- return false;
4173
- }
4174
-
4175
- const normalized = error.message.toLowerCase();
4176
- return (
4177
- normalized.includes("zip") ||
4178
- normalized.includes("opc package") ||
4179
- normalized.includes("compression") ||
4180
- normalized.includes("relationship") ||
4181
- normalized.includes("/[content_types].xml") ||
4182
- normalized.includes("/word/document.xml") ||
4183
- normalized.includes("xml")
4184
- );
4185
- }
4186
-
4187
- const CORE_NON_PRESERVED_PART_PATHS = new Set([
4188
- "/docProps/app.xml",
4189
- "/docProps/core.xml",
4190
- "/word/numbering.xml",
4191
- ]);
4192
-
4193
- function createCommentsRelationshipId(
4194
- relationships: readonly OpcRelationship[],
4195
- ): string {
4196
- let nextIndex = 1;
4197
- while (relationships.some((relationship) => relationship.id === `rIdComments${nextIndex}`)) {
4198
- nextIndex += 1;
4199
- }
4200
-
4201
- return `rIdComments${nextIndex}`;
4202
- }
4203
-
4204
- function toDocumentRelativeTarget(partPath: string): string {
4205
- const normalized = normalizePartPath(partPath);
4206
- return normalized.startsWith("/word/") ? normalized.slice("/word/".length) : normalized.slice(1);
4207
- }
4208
-
4209
- function cloneRelationship(relationship: OpcRelationship): OpcRelationship {
4210
- return { ...relationship };
4211
- }
4212
-
4213
- const UTF8_DECODER = new TextDecoder("utf-8");
4214
-
4215
- function decodeUtf8(bytes: Uint8Array | undefined): string {
4216
- if (!bytes) {
4217
- return "";
4218
- }
4219
-
4220
- return UTF8_DECODER.decode(bytes);
4221
- }
4222
-
4223
- function toUint8Array(bytes: Uint8Array | ArrayBuffer): Uint8Array {
4224
- return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
4225
- }
4226
-
4227
- function serializeCanonicalDocumentForExport(document: CanonicalDocumentEnvelope): string {
4228
- return createCanonicalDocumentSignature({
4229
- ...document,
4230
- docId: "__export__",
4231
- createdAt: "__export__",
4232
- updatedAt: "__export__",
4233
- });
4234
- }
4235
-
4236
- /**
4237
- * Read a Node-style environment variable without referencing the Node-only
4238
- * `process` global in the production build (which excludes @types/node).
4239
- * Returns `undefined` in browser environments or when the var is unset.
4240
- */
4241
- function readNodeEnvVar(name: string): string | undefined {
4242
- const proc = (globalThis as unknown as {
4243
- process?: { env?: Record<string, string | undefined> };
4244
- }).process;
4245
- return proc?.env?.[name];
4246
- }
4247
-
4248
- function canReuseSourceBytesForCurrentDocument(
4249
- state: ImportedDocxState,
4250
- document: CanonicalDocumentEnvelope,
4251
- ): boolean {
4252
- // The validator-loop CI harness (§1 / §7) needs to exercise the full
4253
- // serializer pipeline — including A.1 / A.2 / A.3 / A.6 / A.7 / A.8 /
4254
- // A.9 fixes — so it sets DOCX_VALIDATOR_FORCE_REGEN=1 to disable the
4255
- // fast-path byte-reuse. Never set this flag in production; it forces a
4256
- // full XML regeneration on every export and is only used by the CCEP
4257
- // validator harness to prove our serializer's correctness.
4258
- if (readNodeEnvVar("DOCX_VALIDATOR_FORCE_REGEN") === "1") {
4259
- return false;
4260
- }
4261
- if (requiresHostMetadataNormalization(state.sourcePackage, state.sourceDocumentPartPath)) {
4262
- return false;
4263
- }
4264
-
4265
- const commentThreads = Object.values(document.review.comments);
4266
- const hasLiveComments = commentThreads.some((thread) => thread.anchor.kind !== "detached");
4267
- const hasRuntimeAuthoredActiveRevisions = Object.values(document.review.revisions).some((revision) =>
4268
- revision.status === "open" && revision.metadata?.source === "runtime"
4269
- );
4270
- if (hasRuntimeAuthoredActiveRevisions) {
4271
- return false;
4272
- }
4273
- if (!hasLiveComments) {
4274
- return true;
4275
- }
4276
-
4277
- return Boolean(
4278
- state.sourceCommentsPartPath &&
4279
- state.sourceCommentsExtendedPartPath &&
4280
- state.sourceCommentsIdsPartPath &&
4281
- state.sourcePeoplePartPath,
4282
- );
4283
- }
4284
-
4285
- function requiresHostMetadataNormalization(
4286
- sourcePackage: OpcPackage,
4287
- sourceDocumentPartPath: string,
4288
- ): boolean {
4289
- return (
4290
- isSuspiciouslySkeletalWordPackage(sourcePackage, sourceDocumentPartPath) &&
4291
- !hasHostSafeMetadataPackageStructure(sourcePackage)
4292
- );
4293
- }
4294
-
4295
- function ensureHostMetadataParts(
4296
- exportSession: ReturnType<typeof createExportSession>,
4297
- sourcePackage: OpcPackage,
4298
- document: CanonicalDocumentEnvelope,
4299
- ): void {
4300
- const corePropertiesPart = sourcePackage.parts.get(CORE_PROPERTIES_PART_PATH);
4301
- if (!corePropertiesPart || corePropertiesPart.contentType !== CORE_PROPERTIES_CONTENT_TYPE) {
4302
- exportSession.replaceOwnedPart({
4303
- path: CORE_PROPERTIES_PART_PATH,
4304
- bytes: corePropertiesPart?.bytes ?? new TextEncoder().encode(buildCorePropertiesXml(document)),
4305
- contentType: CORE_PROPERTIES_CONTENT_TYPE,
4306
- compression: corePropertiesPart?.compression,
4307
- });
4308
- }
4309
-
4310
- const appPropertiesPart = sourcePackage.parts.get(APP_PROPERTIES_PART_PATH);
4311
- if (!appPropertiesPart || appPropertiesPart.contentType !== APP_PROPERTIES_CONTENT_TYPE) {
4312
- exportSession.replaceOwnedPart({
4313
- path: APP_PROPERTIES_PART_PATH,
4314
- bytes: appPropertiesPart?.bytes ?? new TextEncoder().encode(buildAppPropertiesXml()),
4315
- contentType: APP_PROPERTIES_CONTENT_TYPE,
4316
- compression: appPropertiesPart?.compression,
4317
- });
4318
- }
4319
-
4320
- exportSession.ensurePackageRelationship({
4321
- type: CORE_PROPERTIES_RELATIONSHIP_TYPE,
4322
- target: CORE_PROPERTIES_PART_PATH,
4323
- preferredId: "rIdDocPropsCore",
4324
- });
4325
- exportSession.ensurePackageRelationship({
4326
- type: APP_PROPERTIES_RELATIONSHIP_TYPE,
4327
- target: APP_PROPERTIES_PART_PATH,
4328
- preferredId: "rIdDocPropsApp",
4329
- });
4330
- }
4331
-
4332
- function ensureWorkflowPayloadParts(
4333
- exportSession: ReturnType<typeof createExportSession>,
4334
- sessionState: EditorSessionState,
4335
- document: CanonicalDocumentEnvelope,
4336
- sourcePackage: OpcPackage,
4337
- resolvedPartPaths: {
4338
- payloadPartPath: string;
4339
- itemPropsPartPath: string;
4340
- },
4341
- editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload,
4342
- ): void {
4343
- const payloadParts = buildWorkflowPayloadParts({
4344
- sourcePackage,
4345
- workflowMetadata: sessionState.workflowMetadata,
4346
- workflowOverlay: sessionState.workflowOverlay,
4347
- editorState,
4348
- documentId: sessionState.documentId,
4349
- createdAt: document.createdAt,
4350
- updatedAt: document.updatedAt,
4351
- producerVersion: sessionState.editorBuild,
4352
- });
4353
- if (!payloadParts) {
4354
- return;
4355
- }
4356
- if (
4357
- payloadParts.payloadPartPath !== resolvedPartPaths.payloadPartPath ||
4358
- payloadParts.itemPropsPartPath !== resolvedPartPaths.itemPropsPartPath
4359
- ) {
4360
- throw new Error(
4361
- "Workflow payload export resolved inconsistent customXml paths; export session ownership no longer matches payload serialization.",
4362
- );
4363
- }
4364
-
4365
- const payloadPart = sourcePackage.parts.get(payloadParts.payloadPartPath);
4366
- const itemPropsPart = sourcePackage.parts.get(payloadParts.itemPropsPartPath);
4367
- const customPropsPart = sourcePackage.parts.get(WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH);
4368
-
4369
- exportSession.replaceOwnedPart({
4370
- path: payloadParts.payloadPartPath,
4371
- bytes: new TextEncoder().encode(payloadParts.payloadPartXml),
4372
- contentType: payloadPart?.contentType ?? WORKFLOW_PAYLOAD_CONTENT_TYPE,
4373
- relationships: payloadParts.payloadRelationships,
4374
- compression: payloadPart?.compression,
4375
- });
4376
- exportSession.replaceOwnedPart({
4377
- path: payloadParts.itemPropsPartPath,
4378
- bytes: new TextEncoder().encode(payloadParts.itemPropsXml),
4379
- contentType: itemPropsPart?.contentType ?? WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
4380
- compression: itemPropsPart?.compression,
4381
- });
4382
- exportSession.replaceOwnedPart({
4383
- path: WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
4384
- bytes: new TextEncoder().encode(payloadParts.customPropertiesXml),
4385
- contentType: customPropsPart?.contentType ?? WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE,
4386
- compression: customPropsPart?.compression,
4387
- });
4388
-
4389
- exportSession.ensurePackageRelationship({
4390
- type: WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE,
4391
- target: WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
4392
- preferredId: "rIdBwWorkflowCustomProps",
4393
- });
4394
- }
4395
-
4396
- function hasHostSafeMetadataPackageStructure(sourcePackage: OpcPackage): boolean {
4397
- const corePropertiesPart = sourcePackage.parts.get(CORE_PROPERTIES_PART_PATH);
4398
- const appPropertiesPart = sourcePackage.parts.get(APP_PROPERTIES_PART_PATH);
4399
- return (
4400
- corePropertiesPart?.contentType === CORE_PROPERTIES_CONTENT_TYPE &&
4401
- appPropertiesPart?.contentType === APP_PROPERTIES_CONTENT_TYPE &&
4402
- hasPackageRelationshipTarget(
4403
- sourcePackage,
4404
- CORE_PROPERTIES_RELATIONSHIP_TYPE,
4405
- CORE_PROPERTIES_PART_PATH,
4406
- ) &&
4407
- hasPackageRelationshipTarget(
4408
- sourcePackage,
4409
- APP_PROPERTIES_RELATIONSHIP_TYPE,
4410
- APP_PROPERTIES_PART_PATH,
4411
- )
4412
- );
4413
- }
4414
-
4415
- function hasPackageRelationshipTarget(
4416
- sourcePackage: OpcPackage,
4417
- relationshipType: string,
4418
- targetPartPath: string,
4419
- ): boolean {
4420
- return sourcePackage.manifest.packageRelationships.some(
4421
- (relationship) =>
4422
- relationship.type === relationshipType &&
4423
- relationship.targetMode === "internal" &&
4424
- resolveRelationshipTarget(null, relationship) === targetPartPath,
4425
- );
4426
- }
4427
-
4428
- function isSuspiciouslySkeletalWordPackage(
4429
- sourcePackage: OpcPackage,
4430
- sourceDocumentPartPath: string,
4431
- ): boolean {
4432
- const allowedPaths = new Set<string>([
4433
- CONTENT_TYPES_PATH,
4434
- PACKAGE_RELATIONSHIPS_PATH,
4435
- sourceDocumentPartPath,
4436
- ]);
4437
- const relationshipsPartPath = getRelationshipsPartPath(sourceDocumentPartPath);
4438
- if (relationshipsPartPath) {
4439
- allowedPaths.add(relationshipsPartPath);
4440
- }
4441
-
4442
- return [...sourcePackage.parts.keys()].every((partPath) => allowedPaths.has(partPath));
4443
- }
4444
-
4445
- function buildCorePropertiesXml(document: CanonicalDocumentEnvelope): string {
4446
- const { metadata } = document;
4447
- const keywords =
4448
- Array.isArray(metadata.keywords) && metadata.keywords.length > 0
4449
- ? metadata.keywords.join(", ")
4450
- : undefined;
4451
- const propertyLines = [
4452
- xmlNode("dc:title", metadata.title),
4453
- xmlNode("dc:subject", metadata.subject),
4454
- xmlNode("dc:description", metadata.description),
4455
- xmlNode("dc:language", metadata.language),
4456
- xmlNode("cp:keywords", keywords),
4457
- xmlNode("cp:category", metadata.category),
4458
- xmlNode('dcterms:created xsi:type="dcterms:W3CDTF"', document.createdAt),
4459
- xmlNode('dcterms:modified xsi:type="dcterms:W3CDTF"', document.updatedAt),
4460
- ].filter((line): line is string => Boolean(line));
4461
-
4462
- return [
4463
- `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
4464
- `<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">`,
4465
- ...propertyLines.map((line) => ` ${line}`),
4466
- `</cp:coreProperties>`,
4467
- ].join("\n");
4468
- }
4469
-
4470
- // buildAppPropertiesXml moved to src/io/export/build-app-properties-xml.ts
4471
- // per close-render-fidelity §2 A.5. Re-import as `buildAppPropertiesXmlFn`.
4472
-
4473
- function xmlNode(tagName: string, value: string | undefined): string | undefined {
4474
- if (typeof value !== "string" || value.length === 0) {
4475
- return undefined;
4476
- }
4477
-
4478
- return `<${tagName}>${escapeXml(value)}</${tagName.split(" ", 1)[0]}>`;
4479
- }
4480
-
4481
- function serializeProtectionRangesIntoDocumentXml(
4482
- documentXml: string,
4483
- protection: ProtectionSnapshot,
4484
- paragraphs = mapParagraphBoundaries(documentXml),
4485
- ): string {
4486
- if (protection.ranges.length === 0) {
4487
- return documentXml;
4488
- }
4489
-
4490
- const insertions = new Map<number, string[]>();
4491
-
4492
- for (const range of protection.ranges) {
4493
- if (typeof range.start !== "number" || typeof range.end !== "number") {
4494
- continue;
4495
- }
4496
- const rangeStart = range.start;
4497
- const rangeEnd = range.end;
4498
-
4499
- const startParagraph = paragraphs.find(
4500
- (candidate) => rangeStart >= candidate.start && rangeStart <= candidate.end,
4501
- );
4502
- const endParagraph = paragraphs.find(
4503
- (candidate) => rangeEnd >= candidate.start && rangeEnd <= candidate.end,
4504
- );
4505
- if (!startParagraph || !endParagraph) {
4506
- continue;
4507
- }
4508
-
4509
- const startIndex =
4510
- startParagraph.boundaries.get(rangeStart) ??
4511
- findNearestBoundaryIndex(startParagraph.boundaries, rangeStart, "backward");
4512
- const endIndex =
4513
- endParagraph.boundaries.get(rangeEnd) ??
4514
- findNearestBoundaryIndex(endParagraph.boundaries, rangeEnd, "forward");
4515
- if (startIndex === undefined || endIndex === undefined) {
4516
- continue;
4517
- }
4518
-
4519
- const permStartXml = [
4520
- `<w:permStart`,
4521
- ` w:id="${escapeXmlAttribute(range.rangeId)}"`,
4522
- range.editorGroup ? ` w:edGrp="${escapeXmlAttribute(range.editorGroup)}"` : "",
4523
- range.editor ? ` w:ed="${escapeXmlAttribute(range.editor)}"` : "",
4524
- `/>`,
4525
- ].join("");
4526
- const permEndXml = `<w:permEnd w:id="${escapeXmlAttribute(range.rangeId)}"/>`;
4527
-
4528
- pushProtectionInsertion(insertions, startIndex, permStartXml);
4529
- pushProtectionInsertion(insertions, endIndex, permEndXml);
4530
- }
4531
-
4532
- if (insertions.size === 0) {
4533
- return documentXml;
4534
- }
4535
-
4536
- const parts: string[] = [];
4537
- let cursor = 0;
4538
- for (const [index, snippets] of [...insertions.entries()].sort(([left], [right]) => left - right)) {
4539
- parts.push(documentXml.slice(cursor, index));
4540
- parts.push(...snippets);
4541
- cursor = index;
4542
- }
4543
- parts.push(documentXml.slice(cursor));
4544
- return parts.join("");
4545
- }
4546
-
4547
- function pushProtectionInsertion(
4548
- insertions: Map<number, string[]>,
4549
- index: number,
4550
- xml: string,
4551
- ): void {
4552
- const existing = insertions.get(index);
4553
- if (existing) {
4554
- existing.push(xml);
4555
- return;
4556
- }
4557
- insertions.set(index, [xml]);
4558
- }
4559
-
4560
- function findNearestBoundaryIndex(
4561
- boundaries: Map<number, number>,
4562
- position: number,
4563
- direction: "backward" | "forward",
4564
- ): number | undefined {
4565
- const ordered = [...boundaries.entries()].sort(([left], [right]) => left - right);
4566
- if (direction === "backward") {
4567
- for (let index = ordered.length - 1; index >= 0; index -= 1) {
4568
- const [boundaryPos, boundaryIndex] = ordered[index]!;
4569
- if (boundaryPos <= position) {
4570
- return boundaryIndex;
4571
- }
4572
- }
4573
- return undefined;
4574
- }
4575
- for (const [boundaryPos, boundaryIndex] of ordered) {
4576
- if (boundaryPos >= position) {
4577
- return boundaryIndex;
4578
- }
4579
- }
4580
- return undefined;
4581
- }
4582
-
4583
- function escapeXml(value: string): string {
4584
- return value
4585
- .replace(/&/g, "&amp;")
4586
- .replace(/</g, "&lt;")
4587
- .replace(/>/g, "&gt;")
4588
- .replace(/\"/g, "&quot;")
4589
- .replace(/'/g, "&apos;");
4590
- }
4591
-
4592
- function escapeXmlAttribute(value: string): string {
4593
- return value
4594
- .replace(/&/g, "&amp;")
4595
- .replace(/</g, "&lt;")
4596
- .replace(/>/g, "&gt;")
4597
- .replace(/"/g, "&quot;");
4598
- }
4599
-
4600
- // ---------------------------------------------------------------------------
4601
- // Protection range extraction
4602
- // ---------------------------------------------------------------------------
4603
-
4604
- const EMPTY_PROTECTION_SNAPSHOT: ProtectionSnapshot = {
4605
- hasDocumentProtection: false,
4606
- enforcementActive: false,
4607
- ranges: [],
4608
- enforcedRangeCount: 0,
4609
- preservedRangeCount: 0,
4610
- };
4611
-
4612
- interface DocumentProtectionMeta {
4613
- editType?: string;
4614
- enforcement: boolean;
4615
- }
4616
-
4617
- function extractDocumentProtection(settingsXml: string): DocumentProtectionMeta {
4618
- if (!settingsXml) return { enforcement: false };
4619
- const match = settingsXml.match(/<w:documentProtection\b([^/>]*)\/?>/);
4620
- if (!match) return { enforcement: false };
4621
- const attrs = match[1];
4622
- const editTypeMatch = attrs.match(/w:edit="([^"]*)"/);
4623
- const enforcementMatch = attrs.match(/w:enforcement="([^"]*)"/);
4624
- const editType = editTypeMatch?.[1];
4625
- const enforcement = enforcementMatch?.[1] === "1" || enforcementMatch?.[1] === "true";
4626
- return { editType, enforcement };
4627
- }
4628
-
4629
- function extractProtectionRanges(blocks: readonly ParsedBlockNode[]): ProtectionRange[] {
4630
- const ranges: ProtectionRange[] = [];
4631
- const openRanges = new Map<string, Omit<ProtectionRange, "end">>();
4632
- collectProtectionRangesFromBlocks(blocks, ranges, openRanges, 0);
4633
- return ranges;
4634
- }
4635
-
4636
- function collectProtectionRangesFromBlocks(
4637
- blocks: readonly ParsedBlockNode[],
4638
- ranges: ProtectionRange[],
4639
- openRanges: Map<string, Omit<ProtectionRange, "end">>,
4640
- cursor: number,
4641
- ): number {
4642
- let nextCursor = cursor;
4643
- let previousParagraph = false;
4644
-
4645
- for (const block of blocks) {
4646
- if (block.type === "paragraph") {
4647
- if (previousParagraph) {
4648
- nextCursor += 1;
4649
- }
4650
- nextCursor = collectProtectionRangesFromInlines(
4651
- block.children,
4652
- ranges,
4653
- openRanges,
4654
- nextCursor,
4655
- );
4656
- previousParagraph = true;
4657
- continue;
4658
- }
4659
-
4660
- if (block.type === "table") {
4661
- nextCursor += 1;
4662
- previousParagraph = false;
4663
- for (const row of block.rows) {
4664
- for (const cell of row.cells) {
4665
- nextCursor = collectProtectionRangesFromBlocks(
4666
- cell.children,
4667
- ranges,
4668
- openRanges,
4669
- nextCursor,
4670
- );
4671
- }
4672
- }
4673
- continue;
4674
- }
4675
-
4676
- if (block.type === "sdt" || block.type === "custom_xml") {
4677
- nextCursor = collectProtectionRangesFromBlocks(
4678
- block.children,
4679
- ranges,
4680
- openRanges,
4681
- nextCursor,
4682
- );
4683
- previousParagraph = false;
4684
- continue;
4685
- }
4686
-
4687
- nextCursor += 1;
4688
- previousParagraph = false;
4689
- }
4690
-
4691
- return nextCursor;
4692
- }
4693
-
4694
- function collectProtectionRangesFromInlines(
4695
- nodes: readonly ParsedInlineNode[],
4696
- ranges: ProtectionRange[],
4697
- openRanges: Map<string, Omit<ProtectionRange, "end">>,
4698
- cursor: number,
4699
- ): number {
4700
- let nextCursor = cursor;
4701
-
4702
- for (const node of nodes) {
4703
- if (node.type === "perm_start") {
4704
- openRanges.set(node.rangeId, {
4705
- rangeId: node.rangeId,
4706
- start: nextCursor,
4707
- ...(node.editorGroup ? { editorGroup: node.editorGroup } : {}),
4708
- ...(node.editor ? { editor: node.editor } : {}),
4709
- enforced: false,
4710
- enforcementReason:
4711
- "preserve-only: runtime does not yet enforce permission range boundaries",
4712
- });
4713
- continue;
4714
- }
4715
-
4716
- if (node.type === "perm_end") {
4717
- const openRange = openRanges.get(node.rangeId);
4718
- if (openRange) {
4719
- ranges.push({
4720
- ...openRange,
4721
- end: nextCursor,
4722
- });
4723
- openRanges.delete(node.rangeId);
4724
- }
4725
- continue;
4726
- }
4727
-
4728
- nextCursor += measureParsedInlineNode(node);
4729
- }
4730
-
4731
- return nextCursor;
4732
- }
4733
-
4734
- function measureParsedInlineNode(node: ParsedInlineNode): number {
4735
- switch (node.type) {
4736
- case "text":
4737
- return Array.from(node.text).length;
4738
- case "tab":
4739
- case "hard_break":
4740
- case "column_break":
4741
- case "footnote_ref":
4742
- case "image":
4743
- case "bookmark_start":
4744
- case "bookmark_end":
4745
- return 1;
4746
- case "hyperlink":
4747
- return node.children.reduce((size, child) => size + measureParsedInlineNode(child), 0);
4748
- case "field": {
4749
- const content = parseMainDocumentXml(
4750
- `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>${node.contentXml}</w:p></w:body></w:document>`,
4751
- );
4752
- if (content.blocks[0]?.type === "paragraph") {
4753
- return content.blocks[0].children.reduce(
4754
- (size, child) => size + measureParsedInlineNode(child),
4755
- 0,
4756
- );
4757
- }
4758
- return 1;
4759
- }
4760
- default:
4761
- return 1;
4762
- }
4763
- }
4764
-
4765
- function buildProtectionSnapshot(
4766
- documentProtection: DocumentProtectionMeta,
4767
- ranges: ProtectionRange[],
4768
- ): ProtectionSnapshot {
4769
- const hasDocumentProtection =
4770
- documentProtection.editType !== undefined || documentProtection.enforcement;
4771
- const enforceRanges =
4772
- documentProtection.editType === "readOnly" || documentProtection.editType === "comments";
4773
- const normalizedRanges = ranges.map((range) => {
4774
- const canEnforce =
4775
- hasDocumentProtection &&
4776
- documentProtection.enforcement &&
4777
- enforceRanges &&
4778
- typeof range.start === "number" &&
4779
- typeof range.end === "number" &&
4780
- range.end >= range.start;
4781
- return {
4782
- ...range,
4783
- enforced: canEnforce,
4784
- enforcementReason: canEnforce
4785
- ? "runtime-enforced: permission range is mapped to canonical positions"
4786
- : "preserve-only: runtime does not yet enforce permission range boundaries",
4787
- };
4788
- });
4789
- return {
4790
- hasDocumentProtection,
4791
- editType: documentProtection.editType,
4792
- enforcementActive: documentProtection.enforcement,
4793
- ranges: normalizedRanges,
4794
- enforcedRangeCount: normalizedRanges.filter((r) => r.enforced).length,
4795
- preservedRangeCount: normalizedRanges.filter((r) => !r.enforced).length,
4796
- };
4797
- }
1
+ // Back-compat shim.
2
+ //
3
+ // `src/io/docx-session.ts` was the pre-refactor home for session
4
+ // orchestration. The Layer-01 refactor extracted the contents to
5
+ // `src/session/**` (see `docs/architecture/01-package-session.md`).
6
+ //
7
+ // This file is retained so that the public export
8
+ // `@beyondwork/docx-react-component/io/docx-session` (declared in
9
+ // `package.json`) and any consumers of the old internal path keep
10
+ // working. New code MUST import from `./session` / `src/session/**`
11
+ // directly — see `MIGRATION.md`.
12
+ //
13
+ // Symbol map (old → new):
14
+ // loadDocxEditorSessionAsync → loadDocxSessionAsync (renamed)
15
+ // LoadDocxEditorSessionAsyncOptions → same name, new home
16
+ // LoadDocxEditorSessionOptions → same name, new home
17
+ // LoadedDocxEditorSession → same name, new home
18
+ // normalizeImportedCommentThreads → same name, new home
19
+ // stripCommentMarkup → same name, new home
20
+ // NormalizedImportedCommentsResult → same name, new home
21
+ //
22
+ // The sync `loadDocxEditorSession` entry point is not re-exported —
23
+ // the runtime is async-first after the refactor. Migrate callers to
24
+ // `DocxSession.open(bytes)` or `loadDocxSessionAsync(...)`.
25
+
26
+ export { loadDocxSessionAsync as loadDocxEditorSessionAsync } from "../session/import/loader.ts";
27
+ export type {
28
+ LoadDocxEditorSessionAsyncOptions,
29
+ LoadDocxEditorSessionOptions,
30
+ LoadedDocxEditorSession,
31
+ } from "../session/import/loader-types.ts";
32
+ export {
33
+ normalizeImportedCommentThreads,
34
+ stripCommentMarkup,
35
+ type NormalizedImportedCommentsResult,
36
+ } from "../session/import/review-import.ts";