@beyondwork/docx-react-component 1.0.36 → 1.0.37

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 (64) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +83 -0
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +173 -11
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/surface-projection.ts +1 -0
  44. package/src/runtime/text-ack-range.ts +49 -0
  45. package/src/ui/WordReviewEditor.tsx +15 -0
  46. package/src/ui/editor-runtime-boundary.ts +10 -1
  47. package/src/ui/editor-surface-controller.tsx +3 -0
  48. package/src/ui/headless/chrome-registry.ts +235 -0
  49. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  50. package/src/ui/headless/selection-tool-context.ts +2 -0
  51. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  52. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  53. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  54. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  55. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  56. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  57. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  58. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  61. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  62. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  63. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  64. package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
package/README.md CHANGED
@@ -1,17 +1,19 @@
1
1
  ---
2
- title: React OOXML Office
3
- summary: Shipped docx package landing page and primary router into consumer, wrapper, agent, and maintainer documentation.
2
+ title: Beyond Work Components
3
+ summary: Backoffice work components landing page and primary router into consumer, wrapper, agent, and maintainer documentation.
4
4
  audience: consumer, wrapper, agent, maintainer
5
5
  stability: main-path
6
6
  docRole: main-path
7
7
  canonical: true
8
8
  ---
9
9
 
10
- # React OOXML Office
10
+ # Beyond Work Components
11
11
 
12
12
  [![CI](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml)
13
13
 
14
- `@beyondwork/docx-react-component` is the shipped product in this repository: a fidelity-first React docx editor centered on `WordReviewEditor`.
14
+ This repository builds **backoffice work components** for the [Beyond Work](https://beyondwork.ai) runtime embeddable, composable modules that handle document editing, review workflows, data processing, and task automation inside enterprise workspaces.
15
+
16
+ The design principle across every component: **advanced tasks should be equally easy for AI agents and human users.** Every capability exposed through the UI is also available through a structured API. Every API is designed so an agent can inspect state, reason about it, and act on it without special accommodation. Humans and agents share the same runtime, the same commands, the same read models, and the same fidelity guarantees.
15
17
 
16
18
  ## Use This README For
17
19
 
@@ -19,11 +21,70 @@ canonical: true
19
21
  - installing the package
20
22
  - finding the right documentation lane quickly
21
23
 
22
- Do not use this README as the full package contract. The canonical consumer path starts in `docs/README.md`, `docs/reference/public-api.md`, and `docs/reference/integration-guide.md`.
24
+ Do not use this README as the full package contract. The canonical consumer path starts in `docs/README.md`, [`docs/reference/public-api.md`](docs/reference/public-api.md), and [`docs/reference/integration-guide.md`](docs/reference/integration-guide.md).
25
+
26
+ ## Components
27
+
28
+ ### Document Editor (`@beyondwork/docx-react-component`)
29
+
30
+ A fidelity-first React `.docx` editor centered on `WordReviewEditor`. Built for legal review, contract negotiation, and any workflow where documents need to survive a round-trip through Microsoft Word without damage.
31
+
32
+ **Capabilities:**
33
+ - Full tracked-change and comment support (add, resolve, accept, reject)
34
+ - Suggesting mode where every edit automatically becomes a tracked change
35
+ - Round-trip OOXML preservation — open, edit, export, reopen in Word without repair prompts
36
+ - Workflow overlays with scope-based editing constraints
37
+ - Runtime-backed read models for document structure, review state, selection, compatibility, and fields
38
+ - Document comparison with LCS-based diffing and redline export
39
+ - Legal document analysis (bookmarks, cross-references, defined terms, signature blocks)
40
+ - Real-time collaboration via Yjs
41
+
42
+ **Human-AI parity in practice:**
43
+ - The human clicks "Add Comment" on selected text; the agent calls `addComment({ anchor, body })` on the same selection
44
+ - The human reviews tracked changes in the sidebar; the agent reads `getTrackedChanges()` and makes the same accept/reject decisions
45
+ - The human exports via toolbar; the agent calls `exportDocx()` after the same `getCompatibilityReport()` check
46
+ - Workflow overlays constrain both humans and agents identically — `getInteractionGuardSnapshot()` is the single source of posture truth
47
+ - The AI action policy module gates 37 discrete agent operations with risk classification and context validation
48
+
49
+ ### Workblocks
50
+
51
+ Composable task modules that automate business processes. Each workblock is a self-contained package of Python backend logic, React UI components, and a declarative task graph defined in `manifest.yaml`. They execute on the Beyond Work platform via Temporal workflows.
52
+
53
+ **Categories:**
54
+ - **Document processing** — invoice extraction, PDF template OCR, bulk processing, email-to-action
55
+ - **AI agents** — explore, flow debugging, data querying, task launching, authoring
56
+ - **Analytics** — execution history, company insights, timeline queries
57
+ - **Learning** — human correction capture, learning set curation, embedding management
58
+ - **Infrastructure** — registry, health checks, credential management (9 types)
59
+
60
+ **Human-AI parity in practice:**
61
+ - Users create workblocks through VS Code Studio with prompts and visual editing; authoring agents create them through the same manifest schema and file tools
62
+ - Users validate invoice fields in a React form; agents validate the same fields through typed task inputs/outputs
63
+ - Both humans and agents operate within the same workblock execution engine — same task graph, same data flow, same error handling
64
+
65
+ ## The Beyond Work Runtime
66
+
67
+ These components plug into a four-layer platform:
68
+
69
+ ```
70
+ User-Facing (Layer 4)
71
+ neui (Next.js 15), bport (BFF), MCP server, VS Code extension
72
+ |
73
+ Workblocks (Layer 3)
74
+ 20+ composable task modules: agents, data processing, credentials
75
+ |
76
+ Platform Services (Layer 2)
77
+ bworker (Go), modelproxy (LiteLLM), registry, pythonworker, Temporal
78
+ |
79
+ AWS Infrastructure (Layer 1)
80
+ EKS, Crossplane, Terraform, PostgreSQL, Redis, Qdrant, S3, SQS
81
+ ```
82
+
83
+ The document editor lives at Layer 4 as an embeddable surface. Workblocks live at Layer 3 and are orchestrated by the platform. Both expose their capabilities symmetrically to human UIs and agent APIs.
23
84
 
24
85
  ## Current Package Reality
25
86
 
26
- The repository may also carry broader branch-local work, but the shipped contract today is still docx-first:
87
+ The shipped contract today is docx-first:
27
88
 
28
89
  - `docx` is the only implemented and shipped runtime contract
29
90
  - `xlsx` is planned work, not part of the current package contract
@@ -93,11 +154,11 @@ The current shipped ESM exports include:
93
154
  - `@beyondwork/docx-react-component/tailwind`
94
155
  - `@beyondwork/docx-react-component/api/public-types`
95
156
 
96
- Use `docs/reference/public-api.md` and `docs/reference/public-api.manifest.json` for the current contract inventory and stability guidance.
157
+ Use [`docs/reference/public-api.md`](docs/reference/public-api.md) and `docs/reference/public-api.manifest.json` for the current contract inventory and stability guidance.
97
158
 
98
159
  ## Product Contract
99
160
 
100
- For every format the repo eventually ships, the standard is:
161
+ For every component this repo ships, the standard is:
101
162
 
102
163
  > Open -> edit -> save -> reopen in the host application without damage.
103
164
 
@@ -158,7 +219,38 @@ Current integration honesty:
158
219
 
159
220
  The CCEP corpus is kept on `main` as a maintainer-safe smoke-doc source set for agreement-heavy validation and wrapper or agent benchmarking.
160
221
 
161
- Maintainer prompts, operator runbooks, and proof/closure material remain important, but they are not the first reading path for package consumers.
222
+ ### Technical Wiki
223
+
224
+ - [`docs/wiki/`](docs/wiki/) — Feature-by-feature technical documentation (25+ topics covering OOXML, ProseMirror, runtime, and platform)
225
+
226
+ ## Agent Integration
227
+
228
+ Agents consume the editor through a consistent pipeline:
229
+
230
+ ```
231
+ inspect --> locate --> mutate --> validate --> export
232
+ ```
233
+
234
+ Beyond Work platform agents extend this with propose/approve gates:
235
+
236
+ ```
237
+ inspect --> locate --> propose --> approve --> mutate --> validate --> export
238
+ ```
239
+
240
+ The package exposes purpose-built read models — not a monolithic state dump:
241
+
242
+ | Read model | Getter | Purpose |
243
+ |---|---|---|
244
+ | Document surface | `getRenderSnapshot()` | Structure, blocks, text, selection |
245
+ | Comments | `getComments()` | Thread state, resolution, anchors |
246
+ | Tracked changes | `getTrackedChanges()` | Revision inventory, actionability |
247
+ | Workflow scope | `getWorkflowScopeSnapshot()` | Scope boundaries, work items, mode |
248
+ | Interaction guard | `getInteractionGuardSnapshot()` | Effective mode, blocked reasons |
249
+ | Compatibility | `getCompatibilityReport()` | Export risk assessment |
250
+ | Warnings | `getWarnings()` | Active fidelity or mutation warnings |
251
+ | Fields | `getFieldSnapshot()` | Field posture and refresh state |
252
+
253
+ Agents should read the narrow snapshot that answers the next question, not pull the full render snapshot for every decision.
162
254
 
163
255
  ## Packaging And Release
164
256
 
@@ -181,9 +273,7 @@ Maintainer prompts, operator runbooks, and proof/closure material remain importa
181
273
 
182
274
  ## Guiding Principle
183
275
 
184
- This repo is not trying to become a generic office clone.
185
-
186
- It is building fidelity-first office-document runtimes with explicit preservation and calm, reviewable UI.
276
+ This repo builds backoffice work components where humans and AI agents are first-class peers. Every capability is designed for both audiences from the start — not bolted on for one after the other. The result is tools that are powerful enough for complex enterprise workflows and structured enough that an agent can operate them safely and predictably.
187
277
 
188
278
  ## Using the package
189
279
 
@@ -663,7 +753,7 @@ Fired when the document transitions between clean and dirty (unsaved) states.
663
753
 
664
754
  #### `story_changed`
665
755
 
666
- Fired when the user navigates between document stories (e.g. main body header/footer).
756
+ Fired when the user navigates between document stories (e.g. main body -> header/footer).
667
757
 
668
758
  ```ts
669
759
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.36",
4
+ "version": "1.0.37",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Package version string — pinned to package.json `version`.
3
+ *
4
+ * Consumed by export paths that embed a producer identity (e.g.
5
+ * `/docProps/app.xml` `<Application>` tag) so exported packages can be
6
+ * traced back to the exact runtime release.
7
+ *
8
+ * Keep this in lockstep with `package.json#version`. A future enhancement
9
+ * would be to generate this file from package.json at build time; for now
10
+ * the string is committed directly so the runtime works without a build
11
+ * step for tests.
12
+ */
13
+ export const PACKAGE_VERSION = "1.0.36";
@@ -1,4 +1,26 @@
1
1
  import type { PersistedEditorSnapshot as RuntimePersistedEditorSnapshot } from "../core/state/editor-state.ts";
2
+ import type { WordReviewEditorLayoutFacet } from "../runtime/layout/public-facet.ts";
3
+
4
+ export type {
5
+ WordReviewEditorLayoutFacet,
6
+ LayoutFacetEvent,
7
+ LayoutFacetInvalidationReason,
8
+ PublicBlockFragment,
9
+ PublicBlockMeasurement,
10
+ PublicFieldDirtinessReport,
11
+ PublicLineBox,
12
+ PublicMeasurementFidelity,
13
+ PublicNoteAllocation,
14
+ PublicPageAnchor,
15
+ PublicPageNode,
16
+ PublicPageRegion,
17
+ PublicPageRegions,
18
+ PublicPageSpan,
19
+ PublicResolvedPageStories,
20
+ PublicResolvedParagraphFormatting,
21
+ PublicResolvedRunFormatting,
22
+ PublicSectionNode,
23
+ } from "../runtime/layout/public-facet.ts";
2
24
 
3
25
  export type FieldFamily = import("../model/canonical-document.ts").FieldFamily;
4
26
  export type FieldRefreshStatus = import("../model/canonical-document.ts").FieldRefreshStatus;
@@ -767,6 +789,7 @@ export type SurfaceBlockSnapshot =
767
789
  keepNext?: boolean;
768
790
  keepLines?: boolean;
769
791
  pageBreakBefore?: boolean;
792
+ widowControl?: boolean;
770
793
  outlineLevel?: number;
771
794
  bidi?: boolean;
772
795
  suppressLineNumbers?: boolean;
@@ -1916,6 +1939,19 @@ export interface WordReviewEditorRef {
1916
1939
  getRuntimeContextAnalytics(
1917
1940
  query?: RuntimeContextAnalyticsQuery,
1918
1941
  ): RuntimeContextAnalyticsSnapshot | null;
1942
+
1943
+ /**
1944
+ * Runtime-owned layout facet.
1945
+ *
1946
+ * The ergonomic surface for walking the page graph, resolving offsets to
1947
+ * fragments, inspecting formatting, and observing layout events. Prefer
1948
+ * this over `getPageLayoutSnapshot()` / `getDocumentNavigationSnapshot()`
1949
+ * for new code — those remain as thin adapters for compatibility.
1950
+ *
1951
+ * Returns `null` only when the runtime is still loading; once the `ready`
1952
+ * event has fired, the facet is always available.
1953
+ */
1954
+ readonly layout: WordReviewEditorLayoutFacet;
1919
1955
  }
1920
1956
 
1921
1957
  export type WordReviewEditorChromePreset =
@@ -1970,3 +2006,50 @@ export interface WordReviewEditorChromeVisibility {
1970
2006
  statusBar: boolean;
1971
2007
  reviewRail: boolean;
1972
2008
  }
2009
+
2010
+ // ---------------------------------------------------------------------------
2011
+ // Bounded local-first predicted-text lane contract
2012
+ // ---------------------------------------------------------------------------
2013
+
2014
+ export type TextCommandAckKind =
2015
+ | "equivalent"
2016
+ | "adjusted"
2017
+ | "rejected"
2018
+ | "structural-divergence";
2019
+
2020
+ export interface ScopeTagTouch {
2021
+ /** Tag family: "comment" | "revision" | "field" | "bookmark" | "sdt" | "opaque" | custom string. */
2022
+ tagType: string;
2023
+ tagId: string;
2024
+ behavior: "extended" | "trimmed" | "split" | "detached" | "unchanged";
2025
+ range: { from: number; to: number };
2026
+ }
2027
+
2028
+ /**
2029
+ * Sync reconciliation result returned by `DocumentRuntime.applyActiveStoryTextCommand`.
2030
+ *
2031
+ * The predicted-text lane in the mounted editor dispatches a local PM transaction,
2032
+ * then calls the runtime synchronously. The ack tells the lane how to reconcile:
2033
+ *
2034
+ * - `equivalent`: runtime accepted the edit and produced the same text shape;
2035
+ * the lane can skip the Lane A PM rebuild entirely.
2036
+ * - `adjusted`: runtime accepted but normalized or added review marks; the lane
2037
+ * patches the affected range from the canonical snapshot.
2038
+ * - `rejected`: runtime blocked the edit (workflow, protection, read-only);
2039
+ * the lane rolls back the predicted PM range from its pre-image.
2040
+ * - `structural-divergence`: the command affected structure beyond the
2041
+ * predicted range; the lane falls back to a full PM rebuild.
2042
+ */
2043
+ export interface TextCommandAck {
2044
+ kind: TextCommandAckKind;
2045
+ /** Opaque id echoed back from the predicted op. Undefined for canonical callers. */
2046
+ opId?: string;
2047
+ /** Revision token of the document AFTER commit. Empty string when `kind === "rejected"`. */
2048
+ newRevisionToken: string;
2049
+ /** For `adjusted`: the canonical range that differs from the prediction. */
2050
+ adjustedRange?: { fromRuntime: number; toRuntime: number };
2051
+ /** For `rejected`: the blocked reasons. */
2052
+ blockedReasons?: readonly { code: string; message: string }[];
2053
+ /** Tag touches the runtime applied so the lane can redraw decorations without a PM rebuild. */
2054
+ scopeTagTouches?: readonly ScopeTagTouch[];
2055
+ }
@@ -35,6 +35,7 @@ import {
35
35
  } from "./text-commands.ts";
36
36
  import type { RevisionRecord as CanonicalRevisionRecord } from "../../model/canonical-document.ts";
37
37
  import { remapCommentThreads } from "../../review/store/comment-remapping.ts";
38
+ import { collectScopeTagTouches } from "../../review/store/scope-tag-diff.ts";
38
39
  import { applyRevisionRuntimeCommand } from "../../runtime/revision-runtime.ts";
39
40
  import type { RevisionStore } from "../../review/store/revision-store.ts";
40
41
 
@@ -878,6 +879,22 @@ function applyTextCommand(
878
879
  result.mapping,
879
880
  );
880
881
 
882
+ const scopeTagTouches = collectScopeTagTouches(
883
+ state.document.review.comments,
884
+ reviewState.document.review.comments,
885
+ state.document.review.revisions,
886
+ reviewState.document.review.revisions,
887
+ );
888
+ const mappingWithTouches: TransactionMapping = scopeTagTouches.length > 0
889
+ ? {
890
+ ...result.mapping,
891
+ metadata: {
892
+ ...(result.mapping.metadata ?? {}),
893
+ scopeTagTouches,
894
+ },
895
+ }
896
+ : result.mapping;
897
+
881
898
  return createTransaction(
882
899
  {
883
900
  ...state,
@@ -892,7 +909,7 @@ function applyTextCommand(
892
909
  {
893
910
  historyBoundary: "push",
894
911
  markDirty: true,
895
- mapping: result.mapping,
912
+ mapping: mappingWithTouches,
896
913
  effects: reviewState.effects,
897
914
  },
898
915
  );
@@ -51,6 +51,12 @@ export interface TransactionMapping {
51
51
  affectsComments?: boolean;
52
52
  affectsRevisions?: boolean;
53
53
  affectsOpaqueFragments?: boolean;
54
+ /**
55
+ * Review-tag touches performed during this transaction. Used by the
56
+ * predicted-text lane's reconciler to classify acks as `adjusted` vs
57
+ * `structural-divergence` without re-walking the document.
58
+ */
59
+ scopeTagTouches?: readonly import("../../api/public-types.ts").ScopeTagTouch[];
54
60
  [key: string]: unknown;
55
61
  };
56
62
  }
@@ -67,6 +67,7 @@ import {
67
67
  createBrokenRelationshipIssue,
68
68
  createMissingPartIssue,
69
69
  } from "./opc/corrupt-package.ts";
70
+ import { buildAppPropertiesXml } from "./export/build-app-properties-xml.ts";
70
71
  import { createExportSession } from "./export/export-session.ts";
71
72
  import { serializeMainDocument } from "./export/serialize-main-document.ts";
72
73
  import {
@@ -2909,10 +2910,31 @@ function serializeCanonicalDocumentForExport(document: CanonicalDocumentEnvelope
2909
2910
  });
2910
2911
  }
2911
2912
 
2913
+ /**
2914
+ * Read a Node-style environment variable without referencing the Node-only
2915
+ * `process` global in the production build (which excludes @types/node).
2916
+ * Returns `undefined` in browser environments or when the var is unset.
2917
+ */
2918
+ function readNodeEnvVar(name: string): string | undefined {
2919
+ const proc = (globalThis as unknown as {
2920
+ process?: { env?: Record<string, string | undefined> };
2921
+ }).process;
2922
+ return proc?.env?.[name];
2923
+ }
2924
+
2912
2925
  function canReuseSourceBytesForCurrentDocument(
2913
2926
  state: ImportedDocxState,
2914
2927
  document: CanonicalDocumentEnvelope,
2915
2928
  ): boolean {
2929
+ // The validator-loop CI harness (§1 / §7) needs to exercise the full
2930
+ // serializer pipeline — including A.1 / A.2 / A.3 / A.6 / A.7 / A.8 /
2931
+ // A.9 fixes — so it sets DOCX_VALIDATOR_FORCE_REGEN=1 to disable the
2932
+ // fast-path byte-reuse. Never set this flag in production; it forces a
2933
+ // full XML regeneration on every export and is only used by the CCEP
2934
+ // validator harness to prove our serializer's correctness.
2935
+ if (readNodeEnvVar("DOCX_VALIDATOR_FORCE_REGEN") === "1") {
2936
+ return false;
2937
+ }
2916
2938
  if (requiresHostMetadataNormalization(state.sourcePackage, state.sourceDocumentPartPath)) {
2917
2939
  return false;
2918
2940
  }
@@ -3108,15 +3130,8 @@ function buildCorePropertiesXml(document: CanonicalDocumentEnvelope): string {
3108
3130
  ].join("\n");
3109
3131
  }
3110
3132
 
3111
- function buildAppPropertiesXml(): string {
3112
- return [
3113
- `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
3114
- `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">`,
3115
- ` <Application>React OOXML Office</Application>`,
3116
- ` <AppVersion>1.0</AppVersion>`,
3117
- `</Properties>`,
3118
- ].join("\n");
3119
- }
3133
+ // buildAppPropertiesXml moved to src/io/export/build-app-properties-xml.ts
3134
+ // per close-render-fidelity §2 A.5. Re-import as `buildAppPropertiesXmlFn`.
3120
3135
 
3121
3136
  function xmlNode(tagName: string, value: string | undefined): string | undefined {
3122
3137
  if (typeof value !== "string" || value.length === 0) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * build-app-properties-xml — synthesises `/docProps/app.xml` for the export.
3
+ *
4
+ * Mirrors `buildCorePropertiesXml` (in `docx-session.ts`) but targets the
5
+ * `extended-properties` namespace. Emitted once per export when the source
6
+ * package does not already carry a valid app.xml part with the correct
7
+ * content type.
8
+ *
9
+ * Required by:
10
+ * docs/plans/close-render-fidelity.md §2 A.5
11
+ *
12
+ * Schema shape (ECMA-376 Part 1, Office Extended properties):
13
+ * <Properties xmlns="…/extended-properties" xmlns:vt="…/docPropsVTypes">
14
+ * <Application>@beyondwork/docx-react-component/<pkg.version></Application>
15
+ * <AppVersion>1.0</AppVersion>
16
+ * <Pages>0</Pages>
17
+ * <Words>0</Words>
18
+ * <Characters>0</Characters>
19
+ * <Lines>0</Lines>
20
+ * <Paragraphs>0</Paragraphs>
21
+ * </Properties>
22
+ *
23
+ * Zero counts are acceptable per the ECMA schema — Word-authoritative counts
24
+ * are recomputed at open time by the hosting application. A future §4 pass
25
+ * can replace these with canonical-derived counts without changing the XML
26
+ * shape.
27
+ */
28
+
29
+ import { PACKAGE_VERSION } from "../../api/package-version.ts";
30
+
31
+ export const APP_PROPERTIES_NAMESPACE =
32
+ "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
33
+ export const APP_PROPERTIES_VT_NAMESPACE =
34
+ "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
35
+
36
+ export interface AppPropertiesStats {
37
+ /** Rendered page count; zero is a valid placeholder. */
38
+ pages?: number;
39
+ /** Word count. */
40
+ words?: number;
41
+ /** Character count. */
42
+ characters?: number;
43
+ /** Line count. */
44
+ lines?: number;
45
+ /** Paragraph count. */
46
+ paragraphs?: number;
47
+ /** App-version string override; defaults to the package version. */
48
+ appVersion?: string;
49
+ /** Application identifier override; defaults to package.json name@version. */
50
+ application?: string;
51
+ }
52
+
53
+ export function buildAppPropertiesXml(
54
+ stats: AppPropertiesStats = {},
55
+ ): string {
56
+ const application = stats.application ?? defaultApplicationLabel();
57
+ const appVersion = stats.appVersion ?? PACKAGE_VERSION;
58
+ const pages = stats.pages ?? 0;
59
+ const words = stats.words ?? 0;
60
+ const characters = stats.characters ?? 0;
61
+ const lines = stats.lines ?? 0;
62
+ const paragraphs = stats.paragraphs ?? 0;
63
+
64
+ return [
65
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
66
+ `<Properties xmlns="${APP_PROPERTIES_NAMESPACE}" xmlns:vt="${APP_PROPERTIES_VT_NAMESPACE}">`,
67
+ ` <Application>${escapeXml(application)}</Application>`,
68
+ ` <AppVersion>${escapeXml(appVersion)}</AppVersion>`,
69
+ ` <Pages>${Math.max(0, Math.round(pages))}</Pages>`,
70
+ ` <Words>${Math.max(0, Math.round(words))}</Words>`,
71
+ ` <Characters>${Math.max(0, Math.round(characters))}</Characters>`,
72
+ ` <Lines>${Math.max(0, Math.round(lines))}</Lines>`,
73
+ ` <Paragraphs>${Math.max(0, Math.round(paragraphs))}</Paragraphs>`,
74
+ `</Properties>`,
75
+ ].join("\n");
76
+ }
77
+
78
+ function defaultApplicationLabel(): string {
79
+ return `@beyondwork/docx-react-component/${PACKAGE_VERSION}`;
80
+ }
81
+
82
+ function escapeXml(value: string): string {
83
+ return value
84
+ .replace(/&/gu, "&amp;")
85
+ .replace(/</gu, "&lt;")
86
+ .replace(/>/gu, "&gt;")
87
+ .replace(/"/gu, "&quot;");
88
+ }
@@ -64,8 +64,13 @@ export interface SerializedCommentDocumentResult {
64
64
  skippedCommentIds: string[];
65
65
  }
66
66
 
67
+ // A.8: emit root attributes in deterministic order (xmlns:* alphabetised,
68
+ // then mc:Ignorable, then other attrs). The sort above happens to produce
69
+ // the same token order `mc < w < w14` + `mc:Ignorable` — keep the literal
70
+ // in sync with renderDeterministicRootAttributes so regenerated docs and
71
+ // comments.xml samples match byte-for-byte when snapshots compare them.
67
72
  const DEFAULT_COMMENTS_ROOT_TAG =
68
- `<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w14">`;
73
+ `<w:comments xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" mc:Ignorable="w14">`;
69
74
  const DEFAULT_COMMENTS_EXTENDED_ROOT_TAG =
70
75
  `<w15:commentsEx xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml">`;
71
76
  const DEFAULT_COMMENTS_IDS_ROOT_TAG =
@@ -16,6 +16,7 @@ import {
16
16
  serializeTablePropertiesXml,
17
17
  serializeTableRowPropertiesXml,
18
18
  } from "./table-properties-xml.ts";
19
+ import { twip } from "./twip.ts";
19
20
 
20
21
  export const WORD_FOOTNOTES_CONTENT_TYPE =
21
22
  "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml";
@@ -142,7 +143,7 @@ function serializeTable(table: TableNode): string {
142
143
  if (table.gridColumns.length > 0) {
143
144
  xml += "<w:tblGrid>";
144
145
  for (const width of table.gridColumns) {
145
- xml += `<w:gridCol w:w="${width}"/>`;
146
+ xml += `<w:gridCol w:w="${twip(width)}"/>`;
146
147
  }
147
148
  xml += "</w:tblGrid>";
148
149
  }
@@ -194,9 +195,9 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
194
195
  }
195
196
  if (paragraph.spacing) {
196
197
  const attrs: string[] = [];
197
- if (paragraph.spacing.before !== undefined) attrs.push(`w:before="${paragraph.spacing.before}"`);
198
- if (paragraph.spacing.after !== undefined) attrs.push(`w:after="${paragraph.spacing.after}"`);
199
- if (paragraph.spacing.line !== undefined) attrs.push(`w:line="${paragraph.spacing.line}"`);
198
+ if (paragraph.spacing.before !== undefined) attrs.push(`w:before="${twip(paragraph.spacing.before)}"`);
199
+ if (paragraph.spacing.after !== undefined) attrs.push(`w:after="${twip(paragraph.spacing.after)}"`);
200
+ if (paragraph.spacing.line !== undefined) attrs.push(`w:line="${twip(paragraph.spacing.line)}"`);
200
201
  if (paragraph.spacing.lineRule) attrs.push(`w:lineRule="${escapeAttribute(paragraph.spacing.lineRule)}"`);
201
202
  if (attrs.length > 0) {
202
203
  parts.push(`<w:spacing ${attrs.join(" ")}/>`);
@@ -204,10 +205,10 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
204
205
  }
205
206
  if (paragraph.indentation) {
206
207
  const attrs: string[] = [];
207
- if (paragraph.indentation.left !== undefined) attrs.push(`w:left="${paragraph.indentation.left}"`);
208
- if (paragraph.indentation.right !== undefined) attrs.push(`w:right="${paragraph.indentation.right}"`);
209
- if (paragraph.indentation.firstLine !== undefined) attrs.push(`w:firstLine="${paragraph.indentation.firstLine}"`);
210
- if (paragraph.indentation.hanging !== undefined) attrs.push(`w:hanging="${paragraph.indentation.hanging}"`);
208
+ if (paragraph.indentation.left !== undefined) attrs.push(`w:left="${twip(paragraph.indentation.left)}"`);
209
+ if (paragraph.indentation.right !== undefined) attrs.push(`w:right="${twip(paragraph.indentation.right)}"`);
210
+ if (paragraph.indentation.firstLine !== undefined) attrs.push(`w:firstLine="${twip(paragraph.indentation.firstLine)}"`);
211
+ if (paragraph.indentation.hanging !== undefined) attrs.push(`w:hanging="${twip(paragraph.indentation.hanging)}"`);
211
212
  if (attrs.length > 0) {
212
213
  parts.push(`<w:ind ${attrs.join(" ")}/>`);
213
214
  }
@@ -330,7 +331,7 @@ function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
330
331
  );
331
332
  break;
332
333
  case "fontSize":
333
- parts.push(`<w:sz w:val="${mark.val}"/>`);
334
+ parts.push(`<w:sz w:val="${twip(mark.val)}"/>`);
334
335
  break;
335
336
  case "textColor":
336
337
  parts.push(`<w:color w:val="${escapeAttribute(mark.color)}"/>`);
@@ -16,6 +16,7 @@ import {
16
16
  serializeTablePropertiesXml,
17
17
  serializeTableRowPropertiesXml,
18
18
  } from "./table-properties-xml.ts";
19
+ import { twip } from "./twip.ts";
19
20
 
20
21
  export const WORD_HEADER_CONTENT_TYPE =
21
22
  "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
@@ -132,7 +133,7 @@ function serializeTable(table: TableNode): string {
132
133
  if (table.gridColumns.length > 0) {
133
134
  xml += "<w:tblGrid>";
134
135
  for (const width of table.gridColumns) {
135
- xml += `<w:gridCol w:w="${width}"/>`;
136
+ xml += `<w:gridCol w:w="${twip(width)}"/>`;
136
137
  }
137
138
  xml += "</w:tblGrid>";
138
139
  }
@@ -182,19 +183,19 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
182
183
  if (paragraph.spacing) {
183
184
  const s = paragraph.spacing;
184
185
  const attrs: string[] = [];
185
- if (s.before !== undefined) attrs.push(`w:before="${s.before}"`);
186
- if (s.after !== undefined) attrs.push(`w:after="${s.after}"`);
187
- if (s.line !== undefined) attrs.push(`w:line="${s.line}"`);
186
+ if (s.before !== undefined) attrs.push(`w:before="${twip(s.before)}"`);
187
+ if (s.after !== undefined) attrs.push(`w:after="${twip(s.after)}"`);
188
+ if (s.line !== undefined) attrs.push(`w:line="${twip(s.line)}"`);
188
189
  if (s.lineRule) attrs.push(`w:lineRule="${escapeAttribute(s.lineRule)}"`);
189
190
  if (attrs.length > 0) parts.push(`<w:spacing ${attrs.join(" ")}/>`);
190
191
  }
191
192
  if (paragraph.indentation) {
192
193
  const ind = paragraph.indentation;
193
194
  const attrs: string[] = [];
194
- if (ind.left !== undefined) attrs.push(`w:left="${ind.left}"`);
195
- if (ind.right !== undefined) attrs.push(`w:right="${ind.right}"`);
196
- if (ind.firstLine !== undefined) attrs.push(`w:firstLine="${ind.firstLine}"`);
197
- if (ind.hanging !== undefined) attrs.push(`w:hanging="${ind.hanging}"`);
195
+ if (ind.left !== undefined) attrs.push(`w:left="${twip(ind.left)}"`);
196
+ if (ind.right !== undefined) attrs.push(`w:right="${twip(ind.right)}"`);
197
+ if (ind.firstLine !== undefined) attrs.push(`w:firstLine="${twip(ind.firstLine)}"`);
198
+ if (ind.hanging !== undefined) attrs.push(`w:hanging="${twip(ind.hanging)}"`);
198
199
  if (attrs.length > 0) parts.push(`<w:ind ${attrs.join(" ")}/>`);
199
200
  }
200
201
  if (paragraph.alignment) {
@@ -203,7 +204,7 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
203
204
  if (paragraph.tabStops && paragraph.tabStops.length > 0) {
204
205
  const tabsXml = paragraph.tabStops.map((tab) => {
205
206
  const leaderAttr = tab.leader ? ` w:leader="${escapeAttribute(tab.leader)}"` : "";
206
- return `<w:tab w:val="${tab.align}" w:pos="${tab.position}"${leaderAttr}/>`;
207
+ return `<w:tab w:val="${tab.align}" w:pos="${twip(tab.position)}"${leaderAttr}/>`;
207
208
  }).join("");
208
209
  parts.push(`<w:tabs>${tabsXml}</w:tabs>`);
209
210
  }
@@ -303,7 +304,7 @@ function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
303
304
  parts.push(`<w:rFonts w:ascii="${escapeAttribute(mark.val)}" w:hAnsi="${escapeAttribute(mark.val)}"/>`);
304
305
  break;
305
306
  case "fontSize":
306
- parts.push(`<w:sz w:val="${mark.val}"/>`);
307
+ parts.push(`<w:sz w:val="${twip(mark.val)}"/>`);
307
308
  break;
308
309
  case "textColor":
309
310
  parts.push(`<w:color w:val="${escapeAttribute(mark.color)}"/>`);