@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -1,5 +1,10 @@
1
1
  import type { InsertTableOptions } from "../../api/public-types";
2
- import type { ParagraphNode } from "../../model/canonical-document.ts";
2
+ import type {
3
+ DocumentRootNode,
4
+ ParagraphNode,
5
+ ParagraphStyleDefinition,
6
+ StylesCatalog,
7
+ } from "../../model/canonical-document.ts";
3
8
  import {
4
9
  createSelectionSnapshot,
5
10
  type CanonicalDocumentEnvelope,
@@ -18,11 +23,102 @@ import {
18
23
  resolveParagraphScope,
19
24
  type StructuralMutationResult,
20
25
  } from "./structural-helpers.ts";
26
+ import { createEditorSurfaceSnapshot } from "../../runtime/surface-projection.ts";
21
27
 
22
28
  export interface TextCommandContext {
23
29
  timestamp: string;
24
30
  }
25
31
 
32
+ /**
33
+ * Walk the `basedOn` chain of paragraph styles looking for a `nextStyle`
34
+ * definition. Returns the resolved `nextStyle` id, or undefined if none is
35
+ * found. Caps the walk at 32 steps to guard against circular `basedOn` chains.
36
+ */
37
+ function resolveNextStyle(
38
+ styleId: string | undefined,
39
+ catalog: StylesCatalog,
40
+ ): string | undefined {
41
+ if (!styleId) {
42
+ return undefined;
43
+ }
44
+ let current: string | undefined = styleId;
45
+ let steps = 0;
46
+ while (current && steps < 32) {
47
+ steps += 1;
48
+ const def: ParagraphStyleDefinition | undefined = catalog.paragraphs[current];
49
+ if (!def) {
50
+ return undefined;
51
+ }
52
+ if (def.nextStyle && catalog.paragraphs[def.nextStyle]) {
53
+ return def.nextStyle;
54
+ }
55
+ current = def.basedOn;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * Given the result document and the head of the new selection (which sits at
62
+ * the start of the newly-created paragraph), locate that paragraph in the
63
+ * top-level `doc` children and return a new document with its `styleId` set to
64
+ * `nextStyleId` and its `numbering` cleared.
65
+ */
66
+ function applyNextStyleToNewParagraph(
67
+ result: TextTransactionResult,
68
+ nextStyleId: string,
69
+ ): TextTransactionResult {
70
+ const root = result.document.content as DocumentRootNode;
71
+ if (!root || root.type !== "doc") {
72
+ return result;
73
+ }
74
+
75
+ const head = result.selection.head;
76
+ const surface = createEditorSurfaceSnapshot(result.document, result.selection);
77
+
78
+ let targetBlockIndex = -1;
79
+ for (let i = 0; i < surface.blocks.length; i += 1) {
80
+ const surfaceBlock = surface.blocks[i];
81
+ if (
82
+ surfaceBlock?.kind === "paragraph" &&
83
+ surfaceBlock.from === head
84
+ ) {
85
+ targetBlockIndex = i;
86
+ break;
87
+ }
88
+ }
89
+
90
+ if (targetBlockIndex === -1) {
91
+ return result;
92
+ }
93
+
94
+ const targetBlock = root.children[targetBlockIndex];
95
+ if (!targetBlock || targetBlock.type !== "paragraph") {
96
+ return result;
97
+ }
98
+
99
+ const { numbering: _numbering, ...restProps } = targetBlock;
100
+ const updatedParagraph: ParagraphNode = {
101
+ ...restProps,
102
+ styleId: nextStyleId,
103
+ children: targetBlock.children,
104
+ };
105
+
106
+ return {
107
+ ...result,
108
+ document: {
109
+ ...result.document,
110
+ content: {
111
+ ...root,
112
+ children: [
113
+ ...root.children.slice(0, targetBlockIndex),
114
+ updatedParagraph,
115
+ ...root.children.slice(targetBlockIndex + 1),
116
+ ],
117
+ },
118
+ },
119
+ };
120
+ }
121
+
26
122
  export function insertText(
27
123
  document: CanonicalDocumentEnvelope,
28
124
  selection: SelectionSnapshot,
@@ -122,7 +218,11 @@ export function splitParagraph(
122
218
  selection: SelectionSnapshot,
123
219
  context: TextCommandContext,
124
220
  ): TextTransactionResult {
125
- return applyTextTransaction(
221
+ // Resolve the current paragraph's styleId before the split so we can look up
222
+ // `nextStyle` from the styles catalog.
223
+ const scope = resolveParagraphScope(document, selection);
224
+
225
+ const result = applyTextTransaction(
126
226
  document,
127
227
  selection,
128
228
  {
@@ -131,6 +231,26 @@ export function splitParagraph(
131
231
  },
132
232
  context,
133
233
  );
234
+
235
+ // Only apply nextStyle for top-level paragraphs; table-cell traversal
236
+ // would require walking into nested blocks which the surface snapshot doesn't expose.
237
+ if (scope?.kind !== "top-level") {
238
+ return result;
239
+ }
240
+
241
+ const originalStyleId = scope.paragraph.styleId;
242
+ const nextStyleId =
243
+ originalStyleId !== undefined
244
+ ? resolveNextStyle(originalStyleId, document.styles)
245
+ : undefined;
246
+
247
+ // If the original paragraph's style specifies a `nextStyle`, apply it to the
248
+ // newly-created paragraph (the one at result.selection.head).
249
+ if (nextStyleId !== undefined) {
250
+ return applyNextStyleToNewParagraph(result, nextStyleId);
251
+ }
252
+
253
+ return result;
134
254
  }
135
255
 
136
256
  export function insertPageBreak(
@@ -1560,6 +1560,7 @@ function filterValidStyleIds(
1560
1560
  characters: filterRecord(catalog.characters),
1561
1561
  tables: filterRecord(catalog.tables),
1562
1562
  ...(catalog.latentStyles ? { latentStyles: catalog.latentStyles } : {}),
1563
+ ...(catalog.docDefaults ? { docDefaults: catalog.docDefaults } : {}),
1563
1564
  ...(catalog.fromPackage !== undefined ? { fromPackage: catalog.fromPackage } : {}),
1564
1565
  };
1565
1566
  }
@@ -1263,20 +1263,11 @@ function offsetParagraphBoundary(
1263
1263
  };
1264
1264
  }
1265
1265
 
1266
- /**
1267
- * Detect a production build without referencing the Node-only `process`
1268
- * global directly (the production tsconfig excludes @types/node). Returns
1269
- * true when NODE_ENV === "production"; otherwise false (browser or dev).
1270
- */
1271
- function isProductionEnvironment(): boolean {
1266
+ function assertUniqueBookmarkIdsOrWarn(documentXml: string): void {
1272
1267
  const proc = (globalThis as unknown as {
1273
1268
  process?: { env?: Record<string, string | undefined> };
1274
1269
  }).process;
1275
- return proc?.env?.NODE_ENV === "production";
1276
- }
1277
-
1278
- function assertUniqueBookmarkIdsOrWarn(documentXml: string): void {
1279
- if (isProductionEnvironment()) {
1270
+ if (proc?.env?.NODE_ENV === "production") {
1280
1271
  return;
1281
1272
  }
1282
1273
  const seen = new Set<string>();
@@ -3,6 +3,7 @@ import {
3
3
  isSyntheticDocxNullAbstractDefinition,
4
4
  isSyntheticDocxNullNumberingInstance,
5
5
  } from "../ooxml/numbering-sentinels.ts";
6
+ import { buildRunPropertiesXml } from "./serialize-run-formatting.ts";
6
7
  import { twip } from "./twip.ts";
7
8
 
8
9
  export const WORD_NUMBERING_CONTENT_TYPE =
@@ -62,35 +63,61 @@ function serializeAbstractDefinition(definition: NumberingCatalog["abstractDefin
62
63
  stripCanonicalPrefix(definition.abstractNumberingId, "abstract-num:"),
63
64
  ),
64
65
  );
66
+
67
+ // ECMA-376 abstractNum child order: nsid, multiLevelType, tmpl, styleLink,
68
+ // numStyleLink — all before the <w:lvl> children.
69
+ const nsid = definition.nsid
70
+ ? `<w:nsid w:val="${escapeAttribute(definition.nsid)}"/>`
71
+ : "";
72
+ const multiLevelType = definition.multiLevelType
73
+ ? `<w:multiLevelType w:val="${escapeAttribute(definition.multiLevelType)}"/>`
74
+ : "";
75
+ const tmpl = definition.tplc
76
+ ? `<w:tmpl w:val="${escapeAttribute(definition.tplc)}"/>`
77
+ : "";
78
+ const styleLink = definition.styleLink
79
+ ? `<w:styleLink w:val="${escapeAttribute(definition.styleLink)}"/>`
80
+ : "";
81
+ const numStyleLink = definition.numStyleLink
82
+ ? `<w:numStyleLink w:val="${escapeAttribute(definition.numStyleLink)}"/>`
83
+ : "";
84
+
65
85
  const levels = [...definition.levels]
66
86
  .sort((left, right) => left.level - right.level)
67
87
  .map((level) => serializeLevel(level))
68
88
  .join("");
69
89
 
70
- return `<w:abstractNum w:abstractNumId="${abstractNumId}">${levels}</w:abstractNum>`;
90
+ return `<w:abstractNum w:abstractNumId="${abstractNumId}">${nsid}${multiLevelType}${tmpl}${styleLink}${numStyleLink}${levels}</w:abstractNum>`;
71
91
  }
72
92
 
73
93
  function serializeLevel(
74
94
  level: NumberingCatalog["abstractDefinitions"][string]["levels"][number],
75
95
  serializedLevel = level.level,
76
96
  ): string {
97
+ // ECMA-376 canonical lvl child order:
98
+ // start, numFmt, lvlRestart, pStyle, isLgl, suff, lvlText, lvlJc, pPr, rPr
77
99
  const start =
78
100
  level.startAt !== undefined
79
101
  ? `<w:start w:val="${clampStart(level.startAt)}"/>`
80
102
  : "";
103
+ const numFmt = `<w:numFmt w:val="${escapeAttribute(level.format)}"/>`;
104
+ const lvlRestart =
105
+ level.restartAfterLevel !== undefined
106
+ ? `<w:lvlRestart w:val="${level.restartAfterLevel}"/>`
107
+ : "";
81
108
  const paragraphStyle = level.paragraphStyleId
82
109
  ? `<w:pStyle w:val="${escapeAttribute(level.paragraphStyleId)}"/>`
83
110
  : "";
84
111
  const isLegal = level.isLegalNumbering ? "<w:isLgl/>" : "";
85
112
  const suffix = level.suffix ? `<w:suff w:val="${escapeAttribute(level.suffix)}"/>` : "";
113
+ const lvlText = `<w:lvlText w:val="${escapeAttribute(level.text)}"/>`;
86
114
  const justification = level.paragraphGeometry?.justification
87
115
  ? `<w:lvlJc w:val="${escapeAttribute(level.paragraphGeometry.justification)}"/>`
88
116
  : "";
89
117
  const paragraphProperties = serializeLevelParagraphGeometry(level.paragraphGeometry);
118
+ const runProperties = buildRunPropertiesXml(level.runProperties);
90
119
 
91
- return `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${start}<w:numFmt w:val="${escapeAttribute(
92
- level.format,
93
- )}"/><w:lvlText w:val="${escapeAttribute(level.text)}"/>${paragraphStyle}${isLegal}${suffix}${justification}${paragraphProperties}</w:lvl>`;
120
+ return `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${start}${numFmt}${lvlRestart}${paragraphStyle}${isLegal}${suffix}${lvlText}${justification}${paragraphProperties}${runProperties}</w:lvl>`;
94
121
  }
95
122
 
96
123
  function serializeLevelOverride(
@@ -101,14 +128,17 @@ function serializeLevelOverride(
101
128
  return "";
102
129
  }
103
130
 
131
+ // ECMA-376 canonical lvl child order (override subset):
132
+ // start, numFmt, lvlRestart, pStyle, isLgl, suff, lvlText, lvlJc, pPr, rPr
104
133
  const start =
105
134
  level.startAt !== undefined
106
135
  ? `<w:start w:val="${clampStart(level.startAt)}"/>`
107
136
  : "";
108
137
  const format = level.format ? `<w:numFmt w:val="${escapeAttribute(level.format)}"/>` : "";
109
- const text = level.text !== undefined
110
- ? `<w:lvlText w:val="${escapeAttribute(level.text)}"/>`
111
- : "";
138
+ const lvlRestart =
139
+ level.restartAfterLevel !== undefined
140
+ ? `<w:lvlRestart w:val="${level.restartAfterLevel}"/>`
141
+ : "";
112
142
  const paragraphStyle = level.paragraphStyleId
113
143
  ? `<w:pStyle w:val="${escapeAttribute(level.paragraphStyleId)}"/>`
114
144
  : "";
@@ -124,11 +154,15 @@ function serializeLevelOverride(
124
154
  ? `<w:isLgl w:val="false"/>`
125
155
  : "";
126
156
  const suffix = level.suffix ? `<w:suff w:val="${escapeAttribute(level.suffix)}"/>` : "";
157
+ const text = level.text !== undefined
158
+ ? `<w:lvlText w:val="${escapeAttribute(level.text)}"/>`
159
+ : "";
127
160
  const justification = level.paragraphGeometry?.justification
128
161
  ? `<w:lvlJc w:val="${escapeAttribute(level.paragraphGeometry.justification)}"/>`
129
162
  : "";
130
163
  const paragraphProperties = serializeLevelParagraphGeometry(level.paragraphGeometry);
131
- const body = `${start}${format}${text}${paragraphStyle}${isLegal}${suffix}${justification}${paragraphProperties}`;
164
+ const runProperties = buildRunPropertiesXml(level.runProperties);
165
+ const body = `${start}${format}${lvlRestart}${paragraphStyle}${isLegal}${suffix}${text}${justification}${paragraphProperties}${runProperties}`;
132
166
 
133
167
  return body.length > 0
134
168
  ? `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${body}</w:lvl>`
@@ -276,8 +310,7 @@ function warnClamp(attr: string, from: number, to: number): void {
276
310
  warnedClamps += 1;
277
311
  // Only warn outside of production builds. The test runner shows these
278
312
  // via console.warn automatically; in production this is a silent no-op
279
- // so the Buffer cost is zero. Read NODE_ENV through globalThis so the
280
- // production tsconfig (which excludes @types/node) type-checks cleanly.
313
+ // so the Buffer cost is zero.
281
314
  const proc = (globalThis as unknown as {
282
315
  process?: { env?: Record<string, string | undefined> };
283
316
  }).process;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Serialize a `CanonicalParagraphFormatting` back into an OOXML `<w:pPr>` fragment.
3
+ * Returns empty when the input has no fields. Emits elements in ECMA-376 canonical
4
+ * order so the OpenXML SDK validator accepts the output.
5
+ */
6
+
7
+ import type {
8
+ CanonicalParagraphFormatting,
9
+ ParagraphBorders,
10
+ ParagraphIndentation,
11
+ ParagraphShading,
12
+ ParagraphSpacing,
13
+ TabStop,
14
+ } from "../../model/canonical-document.ts";
15
+ import { buildRunPropertiesXml } from "./serialize-run-formatting.ts";
16
+
17
+ function escXml(value: string): string {
18
+ return value
19
+ .replace(/&/g, "&amp;")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;");
23
+ }
24
+
25
+ function toggleEl(tag: string, value: boolean | undefined): string {
26
+ if (value === undefined) return "";
27
+ return value ? `<w:${tag}/>` : `<w:${tag} w:val="0"/>`;
28
+ }
29
+
30
+ function borderAttrs(b: {
31
+ value?: string;
32
+ size?: number;
33
+ space?: number;
34
+ color?: string;
35
+ }): string {
36
+ const attrs: string[] = [];
37
+ if (b.value) attrs.push(`w:val="${escXml(b.value)}"`);
38
+ if (b.size !== undefined) attrs.push(`w:sz="${b.size}"`);
39
+ if (b.space !== undefined) attrs.push(`w:space="${b.space}"`);
40
+ if (b.color) attrs.push(`w:color="${escXml(b.color)}"`);
41
+ return attrs.join(" ");
42
+ }
43
+
44
+ function buildBordersXml(b: ParagraphBorders | undefined): string {
45
+ if (!b) return "";
46
+ const sides = ["top", "left", "bottom", "right", "between", "bar"] as const;
47
+ const parts: string[] = [];
48
+ for (const side of sides) {
49
+ const spec = b[side];
50
+ if (!spec) continue;
51
+ const attrs = borderAttrs(spec);
52
+ if (attrs) parts.push(`<w:${side} ${attrs}/>`);
53
+ }
54
+ return parts.length > 0 ? `<w:pBdr>${parts.join("")}</w:pBdr>` : "";
55
+ }
56
+
57
+ function buildShadingXml(s: ParagraphShading | undefined): string {
58
+ if (!s) return "";
59
+ const attrs: string[] = [];
60
+ if (s.val) attrs.push(`w:val="${escXml(s.val)}"`);
61
+ if (s.color) attrs.push(`w:color="${escXml(s.color)}"`);
62
+ if (s.fill) attrs.push(`w:fill="${escXml(s.fill)}"`);
63
+ return attrs.length > 0 ? `<w:shd ${attrs.join(" ")}/>` : "";
64
+ }
65
+
66
+ function buildTabsXml(tabs: TabStop[] | undefined): string {
67
+ if (!tabs || tabs.length === 0) return "";
68
+ const parts = tabs.map((t) => {
69
+ // Canonical "middleDot" → OOXML "middledot"
70
+ const leader = t.leader === "middleDot" ? "middledot" : t.leader;
71
+ const attrs: string[] = [`w:val="${escXml(t.align)}"`, `w:pos="${t.position}"`];
72
+ if (leader) attrs.push(`w:leader="${escXml(leader)}"`);
73
+ return `<w:tab ${attrs.join(" ")}/>`;
74
+ });
75
+ return `<w:tabs>${parts.join("")}</w:tabs>`;
76
+ }
77
+
78
+ function buildSpacingXml(s: ParagraphSpacing | undefined): string {
79
+ if (!s) return "";
80
+ const attrs: string[] = [];
81
+ if (s.before !== undefined) attrs.push(`w:before="${s.before}"`);
82
+ if (s.after !== undefined) attrs.push(`w:after="${s.after}"`);
83
+ if (s.line !== undefined) attrs.push(`w:line="${s.line}"`);
84
+ if (s.lineRule) attrs.push(`w:lineRule="${escXml(s.lineRule)}"`);
85
+ return attrs.length > 0 ? `<w:spacing ${attrs.join(" ")}/>` : "";
86
+ }
87
+
88
+ function buildIndentXml(i: ParagraphIndentation | undefined): string {
89
+ if (!i) return "";
90
+ const attrs: string[] = [];
91
+ if (i.left !== undefined) attrs.push(`w:left="${i.left}"`);
92
+ if (i.right !== undefined) attrs.push(`w:right="${i.right}"`);
93
+ if (i.firstLine !== undefined) attrs.push(`w:firstLine="${i.firstLine}"`);
94
+ if (i.hanging !== undefined) attrs.push(`w:hanging="${i.hanging}"`);
95
+ return attrs.length > 0 ? `<w:ind ${attrs.join(" ")}/>` : "";
96
+ }
97
+
98
+ export function buildParagraphPropertiesXml(
99
+ pPr: CanonicalParagraphFormatting | undefined,
100
+ ): string {
101
+ if (!pPr) return "";
102
+ const parts: string[] = [];
103
+
104
+ // ECMA-376 canonical pPr child order (subset we model):
105
+ // 1. keepNext, keepLines, pageBreakBefore
106
+ parts.push(toggleEl("keepNext", pPr.keepNext));
107
+ parts.push(toggleEl("keepLines", pPr.keepLines));
108
+ parts.push(toggleEl("pageBreakBefore", pPr.pageBreakBefore));
109
+
110
+ // 4. pBdr
111
+ parts.push(buildBordersXml(pPr.borders));
112
+
113
+ // 5. shd
114
+ parts.push(buildShadingXml(pPr.shading));
115
+
116
+ // 6. tabs
117
+ parts.push(buildTabsXml(pPr.tabStops));
118
+
119
+ // 7. spacing
120
+ parts.push(buildSpacingXml(pPr.spacing));
121
+
122
+ // 8. ind
123
+ parts.push(buildIndentXml(pPr.indentation));
124
+
125
+ // 9. contextualSpacing
126
+ parts.push(toggleEl("contextualSpacing", pPr.contextualSpacing));
127
+
128
+ // 10. widowControl
129
+ parts.push(toggleEl("widowControl", pPr.widowControl));
130
+
131
+ // 11. suppressLineNumbers, suppressAutoHyphens
132
+ parts.push(toggleEl("suppressLineNumbers", pPr.suppressLineNumbers));
133
+ parts.push(toggleEl("suppressAutoHyphens", pPr.suppressAutoHyphens));
134
+
135
+ // 12. bidi
136
+ parts.push(toggleEl("bidi", pPr.bidi));
137
+
138
+ // 13. jc
139
+ if (pPr.alignment) parts.push(`<w:jc w:val="${escXml(pPr.alignment)}"/>`);
140
+
141
+ // 14. outlineLvl
142
+ if (pPr.outlineLevel !== undefined) parts.push(`<w:outlineLvl w:val="${pPr.outlineLevel}"/>`);
143
+
144
+ // 15. rPr (paragraph mark)
145
+ if (pPr.paragraphMarkRunProperties) {
146
+ const markXml = buildRunPropertiesXml(pPr.paragraphMarkRunProperties);
147
+ if (markXml) parts.push(markXml);
148
+ }
149
+
150
+ const body = parts.filter(Boolean).join("");
151
+ return body.length > 0 ? `<w:pPr>${body}</w:pPr>` : "";
152
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Serialize a `CanonicalRunFormatting` back into an OOXML `<w:rPr>` fragment.
3
+ * Returns an empty string when the input has no fields, so callers can safely
4
+ * concatenate without emitting a `<w:rPr/>` husk.
5
+ *
6
+ * Elements are emitted in ECMA-376 canonical order so the OpenXML SDK
7
+ * validator accepts the output.
8
+ */
9
+
10
+ import type { CanonicalRunFormatting } from "../../model/canonical-document.ts";
11
+
12
+ function escXml(value: string): string {
13
+ return value
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;");
18
+ }
19
+
20
+ function toggleEl(tag: string, value: boolean | undefined): string {
21
+ if (value === undefined) return "";
22
+ return value ? `<w:${tag}/>` : `<w:${tag} w:val="0"/>`;
23
+ }
24
+
25
+ export function buildRunPropertiesXml(rPr: CanonicalRunFormatting | undefined): string {
26
+ if (!rPr) return "";
27
+ const parts: string[] = [];
28
+
29
+ // 1. rStyle
30
+ if (rPr.characterStyleId) parts.push(`<w:rStyle w:val="${escXml(rPr.characterStyleId)}"/>`);
31
+
32
+ // 2. rFonts
33
+ if (rPr.fontFamilyAscii || rPr.fontFamilyHAnsi || rPr.fontFamilyEastAsia || rPr.fontFamilyCs) {
34
+ const attrs: string[] = [];
35
+ if (rPr.fontFamilyAscii) attrs.push(`w:ascii="${escXml(rPr.fontFamilyAscii)}"`);
36
+ if (rPr.fontFamilyHAnsi) attrs.push(`w:hAnsi="${escXml(rPr.fontFamilyHAnsi)}"`);
37
+ if (rPr.fontFamilyEastAsia) attrs.push(`w:eastAsia="${escXml(rPr.fontFamilyEastAsia)}"`);
38
+ if (rPr.fontFamilyCs) attrs.push(`w:cs="${escXml(rPr.fontFamilyCs)}"`);
39
+ parts.push(`<w:rFonts ${attrs.join(" ")}/>`);
40
+ }
41
+
42
+ // 3. b, bCs (bCs not modeled, skip)
43
+ parts.push(toggleEl("b", rPr.bold));
44
+
45
+ // 4. i, iCs (iCs not modeled, skip)
46
+ parts.push(toggleEl("i", rPr.italic));
47
+
48
+ // 5. caps, smallCaps
49
+ parts.push(toggleEl("caps", rPr.allCaps));
50
+ parts.push(toggleEl("smallCaps", rPr.smallCaps));
51
+
52
+ // 6. strike, dstrike
53
+ parts.push(toggleEl("strike", rPr.strikethrough));
54
+ parts.push(toggleEl("dstrike", rPr.doubleStrikethrough));
55
+
56
+ // 7. vanish
57
+ parts.push(toggleEl("vanish", rPr.vanish));
58
+
59
+ // 8. color
60
+ if (rPr.colorHex || rPr.colorThemeSlot) {
61
+ const attrs: string[] = [];
62
+ if (rPr.colorHex) attrs.push(`w:val="${escXml(rPr.colorHex)}"`);
63
+ if (rPr.colorThemeSlot) attrs.push(`w:themeColor="${escXml(rPr.colorThemeSlot)}"`);
64
+ parts.push(`<w:color ${attrs.join(" ")}/>`);
65
+ }
66
+
67
+ // 9. spacing (character spacing)
68
+ if (rPr.characterSpacingTwips !== undefined) {
69
+ parts.push(`<w:spacing w:val="${rPr.characterSpacingTwips}"/>`);
70
+ }
71
+
72
+ // 10. highlight
73
+ if (rPr.highlight) parts.push(`<w:highlight w:val="${escXml(rPr.highlight)}"/>`);
74
+
75
+ // 11. u (underline)
76
+ if (rPr.underline) parts.push(`<w:u w:val="${escXml(rPr.underline)}"/>`);
77
+
78
+ // 12. vertAlign
79
+ if (rPr.verticalAlign) parts.push(`<w:vertAlign w:val="${escXml(rPr.verticalAlign)}"/>`);
80
+
81
+ // 13. lang
82
+ if (rPr.languageCode) parts.push(`<w:lang w:val="${escXml(rPr.languageCode)}"/>`);
83
+
84
+ // 14. sz, szCs
85
+ if (rPr.fontSizeHalfPoints !== undefined) parts.push(`<w:sz w:val="${rPr.fontSizeHalfPoints}"/>`);
86
+ if (rPr.fontSizeCsHalfPoints !== undefined) parts.push(`<w:szCs w:val="${rPr.fontSizeCsHalfPoints}"/>`);
87
+
88
+ const body = parts.filter(Boolean).join("");
89
+ return body.length > 0 ? `<w:rPr>${body}</w:rPr>` : "";
90
+ }